进程与线程
线程和进程有什么区别?
线程和进程有什么区别?
进程是操作系统分配资源的最小单位,线程是 CPU 调度的最小单位。
每个进程有自己独立的内存空间,包括代码段、数据段、堆等,可以看作是一个正在运行的程序实例,且各进程之间相互独立。
线程属于进程,一个进程可以包含多个线程。线程共享进程的内存空间和资源,比如文件句柄、数据段,但每个线程有自己独立的栈和寄存器。
线程共享进程内的:
- 代码段,所有线程执行的是同一份程序代码。
- 全局变量,多个线程访问的是同一块内存。这也是为什么会有线程安全问题。
- 堆内存
- 文件描述符,线程共享进程打开的文件,创建的socket连接等
线程私有的部分:程序计数器,虚拟机栈,寄存器等
因此线程和进程的区别主要在四个地方:
- 内存空间
- 进程之间是隔离的,就像两个车间,墙是封死的,数据不能随便乱串。线程之间是共享的,同一个车间里的工人可以共用堆内存、代码段和全局变量,只有栈空间和寄存器是私有的。
- 创建和切换的成本
- 进程的创建需要分配各种资源,比如linux里的fork方法,会分配PID,复制页表结构,建立虚拟地址空间,复制文件描述符等等,而线程的创建不需要复制页表,也不需要创建新的内存管理结构。
- 进程的切换需要切换程序计数器,cpu寄存器,页表,栈等,并且还会刷新页表缓存,也就是TLB导致缓存失效,而线程之需要保存寄存器,程序计数器,切换栈即可
- 通信方式
- 进程间通信比较麻烦,因为隔着墙,得用管道、消息队列或者共享内存等。线程间通信非常简单,因为大家都在一个屋檐下,直接读写共享变量就行,但要注意同步问题,防止两个线程同时抢一个资源出问题。
- 稳定性
- 进程更安全,一个进程崩溃不会影响其他进程,因为进程间相互独立,其他进程还能照常跑。线程风险大,一个线程崩了,可能导致其他线程也会受影响,进而导致进程崩溃。
上下文切换到底切了什么?
CPU 执行代码时,寄存器里存着当前的执行状态:程序计数器指向下一条指令,栈指针指向当前栈顶,还有一堆通用寄存器存着中间计算结果。
线程切换时,内核要把这些寄存器的值保存到内存里的 task_struct 中,然后把另一个线程保存的值恢复到寄存器。这个过程本身不慢,几微秒的事。
真正慢的是缓存失效。
CPU 有 L1、L2、L3 缓存,切换线程后,新线程访问的数据大概率不在缓存里,要从内存重新加载,这比访问缓存慢 100 倍。进程切换更惨,还要切换页表,TLB 也会失效。
所以高性能服务器都尽量减少线程切换。Nginx 用少量 worker 进程,每个进程单线程跑事件循环。Netty 也是类似思路,用少量线程配合 epoll 处理海量连接。
| 切换类型 | 需要保存/恢复的内容 | 额外开销 |
|---|---|---|
| 协程切换 | 少量寄存器、栈指针 | 无 |
| 线程切换 | 全部寄存器 | 内核态切换、缓存可能失效 |
| 进程切换 | 全部寄存器 | 内核态切换、页表切换、TLB 失效 |
为什么说线程共享堆但不共享栈?
原因在于,堆是用来存动态分配的对象的,整个进程就一个堆,所有线程 new 出来的对象都在里面,所以天然共享。
栈是用来存函数调用链和局部变量的,每个线程的调用路径不一样,执行到哪一步也不一样,必须各自有独立的栈。并且由于线程执行任务需要获得cpu时间片,时间片结束没执行完需要保存当前寄存器,并在下次获得时间片时恢复寄存器继续执行。如果共享栈,一个线程调用函数把栈顶指针改了,另一个线程就完全乱套了。
进程间的通信方式
- 管道 是最简单的 IPC 方式,匿名管道用于亲缘关系的父子或兄弟进程间通信。命名管道遵循先进先出,以磁盘文件方式存在,可实现本机任意两个进程通信。
- 信号 (Signal): 通知接收进程某个事件已经发生
- 消息队列(Message Queuing): 消息队列是消息链表,有特定格式,存于内存,由标识符标识。管道和消息队列通信数据皆遵循先进先出原则。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取。
- 消息量 (Semaphores): 信号量是一个计数器,用于多进程对共享数据的访问,用于进程间同步。解决与同步相关的问题并且避免了竞争条件。
- 共享内存(Shared memory): 最快的 IPC 方式,使得多个进程可以访问同一块内存空间,及时看到别的进程对于共享内存中的数据更新
- 套接字 (Sockets): 最灵活的IPC方式,主要用于再客户端和服务器之间通过网络进行通信。套接字支持 TCP/IP 的网络通信的基本操作单一,可以看做是不同主机之间的进程进行双向通信的端点。
管道
管道实质是一个内核缓冲区,进程以先进先出的方式从缓冲区中存取数据。每次写数据需要将用户态的内容拷贝到内核态,取数据也需要从内核态拷贝到用户态。
传输的数据是字节流,因此不具备边界。
匿名管道
匿名管道没有路径、没有名字、没有标识符。在创建时会返回两个 fd,分别表示读和写,只有这两个 fd 能访问它。
但由于其他进程拿到不到这两个fd,而主进程fork出的子进程会共享主进程的fd资源,因此匿名管道只有具备亲缘关系的进程才能使用,并且是单向的
命名管道
支持双向通信通过命令mkfifo创建,如果两个进程open的有名管道路径一致,就能进行通信。但通信效率很低
共享内存
管道需要数据在进程和内核之间进行拷贝,共享内存不一样,多个进程的虚拟地址映射到同一块物理内存。进程 A 写了个数据,进程 B 立刻就能看到,压根不需要内核介入,也不需要数据拷贝。因此可以说共享内存是零拷贝的 IPC。
但共享内存有个大坑:内核不管同步。
两个进程同时写同一块内存,数据就乱套了。所以实际使用必须配合信号量或者互斥锁。
共享内存的使用流程:
- 进程A调用 shm_open 创建共享内存对象
- 进程A调用 ftruncate 设置共享内存大小
- 进程A调用 mmap 将共享内存映射到自己的地址空间
- 进程B调用 shm_open 打开同一个共享内存对象
- 进程B调用 mmap 将共享内存映射到自己的地址空间
- 两个进程通过映射的地址直接读写同一块物理内存
消息队列
管道是字节流,没有边界的概念,写 100 字节可能分 3 次读完。消息队列是有边界的,一条消息要么读完要么不读,不会读一半。
消息队列还支持按类型读取。比如进程 A 发类型为 1 的消息,进程 B 发类型为 2 的消息,接收方可以指定只读类型 1 的消息,其他的留在队列里。
Socket套接字
实现跨网络与不同主机上的进程之间通信,需要用到 Socket通信。凭借这种机制,客户端/服务器系统的开发工作既可以在本地单机上进行,也可以跨网络进行。
- 服务端和客户端初始化 socket 得文件描述符。
- 服务端 bind 绑定 IP 和端口,listen 监听,accept 等客户端连接。
- 客户端 connect 向服务端地址和端口发起连接请求。
- 服务端 accept 返回传输 socket 文件描述符。
- 客户端 write 写入数据,服务端 read 读取。
- 客户端 close 时服务端 read 到 EOF,处理完数据后服务端 close 关闭连接
Q&A
信号和信号量有什么区别?
信号是异步通知机制,一个进程给另一个进程发个信号,告诉它发生了某件事,比如 SIGKILL 让进程立即退出,SIGCHLD 通知父进程子进程状态变了。信号不能传递数据,就是一个编号。
信号量是同步原语,用来控制对共享资源的访问,本质是个计数器,P 操作减一,V 操作加一,减到 0 就阻塞。典型用法是保护共享内存,或者实现生产者消费者模型。
跨机器的进程通信一般用什么方案?
跨机器只能走网络,底层都是 Socket。但直接用 Socket 编程太麻烦了,一般都用封装好的框架。比如同步调用可以用 RPC 框架,比如 gRPC、Dubbo、Thrift。
异步通信可以用消息队列,比如 Kafka、RocketMQ、RabbitMQ。
微服务之间互相调用基本都是 HTTP 或者 gRPC。选择主要看场景,需要实时响应用 RPC,允许延迟、需要削峰填谷用消息队列。
实际项目中什么时候用管道,什么时候用共享内存?
数据量小、通信不频繁就用管道,简单省事。比如主进程 fork 出子进程执行命令,通过管道拿结果,几百字节的事,用管道就够了。
数据量大、通信频繁就用共享内存,比如视频处理软件,解码进程和渲染进程之间每秒传几十帧画面,每帧好几 MB,用管道根本扛不住。
Redis 的 RDB 持久化也用到了共享内存的思想,fork 出子进程后利用写时复制,子进程能读到父进程的内存数据
为什么说共享内存最快?快多少?
快在不需要数据拷贝。管道和消息队列都要把数据从用户空间拷到内核空间,再从内核空间拷到另一个进程的用户空间,两次拷贝。
共享内存是多个进程的页表直接指向同一块物理内存,写进去立刻就能被另一个进程看到,CPU 不用搬运数据。具体快多少取决于数据量,传输几 MB 数据的话,共享内存可能比管道快 10 倍以上。但共享内存本身没有同步机制,加上锁之后优势会打点折扣。
操作系统中的进程有哪几种状态?
进程状态可以用去银行办业务来理解,核心就三个:排队等叫号、正在柜台办、去旁边填单子。
对应到操作系统里就是就绪、运行、阻塞,但在操作系统层面,完整的生命周期包含 5 到 7 种状态。
基础五种状态:
- 新建态:正在创建 但是还没就绪。操作系统正在给进程分配 PID 和 PCB。
- 就绪态:已有资源 等待CPU,CPU 一空闲就能立马上去运行。
- 运行态:进程正在使用 CPU 执行指令。单核 CPU 同一时刻只能有一个进程在运行,多核可以并行跑多个。
- 阻塞态:进程主动放弃CPU,去等待IO完成,等信号量,或者是等锁释放
- 终止态:执行完毕或者因为某些原因被终止,进程结束,资源被回收。
进程状态转换流程:
- 新建态完成初始化后进入就绪态
- 就绪态被 CPU 调度后进入运行态
- 运行态时间片用完或被抢占回到就绪态
- 运行态等待 I/O 或事件进入阻塞态
- 阻塞态等待的事件完成后回到就绪态
- 运行态执行完毕或异常退出进入终止态
状态转换的触发条件
就绪 → 运行:CPU 调度器选中了这个进程,把它从就绪队列里拿出来执行。Linux 默认用 CFS 调度器,按虚拟运行时间排序,谁欠的时间多就先跑谁。
运行 → 就绪:两种情况。一是时间片耗尽,进程没跑完但该让别人跑了;二是被更高优先级的进程抢占,比如实时进程进入就绪态,普通进程立马让位。
运行 → 阻塞:进程主动放弃 CPU,去等 I/O 完成、等信号量、等锁释放。常见的 read 系统调用读磁盘文件,进程就会进入阻塞态,让 CPU 去干别的事。
阻塞 → 就绪:等待的事件完成了,比如磁盘数据读完了、收到信号了、锁释放了,进程重新进入就绪队列等待调度。注意不是直接变运行态,还得排队。
进程从阻塞态能直接变成运行态吗?
不能,必须先回到就绪态排队。阻塞态的进程等待的事件完成后,操作系统只是把它放回就绪队列,什么时候真正上 CPU 还得看调度器的安排。如果当前 CPU 正在跑一个高优先级进程,刚解除阻塞的进程可能还得等很久。
零拷贝
零拷贝(Zero-Copy) 是一种高效的数据传输技术,它可以将数据从内核空间直接传输到应用程序的内存空间中,避免了不必要的数据拷贝,从而提高了数据传输的效率和性能。
在传统的数据传输方式中,当应用程序需要从磁盘、网络等外部设备中读取数据时,操作系统需要先将数据从外部设备拷贝到内核空间的缓冲区,然后再将数据从内核空间拷贝到应用程序的内存空间中,这个过程中需要进行两次数据拷贝,浪费了大量的 CPU 时间和内存带宽。
而使用零拷贝技术,数据可以直接从外部设备复制到应用程序的内存空间中,避免了中间的内核空间缓冲区,减少了不必要的数据拷贝,提高了数据传输的效率和性能。
在网络编程中,零拷贝技术可以用于大文件的传输、网络文件系统的读写、数据库查询等场景中,提高数据传输的效率和响应速度。同时,零拷贝技术也可以减少系统内存的开销,提高系统的稳定性和可靠性。
以linux为例子,传输数据需要经过一下流程
磁盘 → 内核缓冲区 → 用户缓冲区 → 内核 socket 缓冲区 → 网卡
1️⃣ DMA:磁盘 → 内核页缓存 2️⃣ CPU:内核 → 用户缓冲区(read) 3️⃣ CPU:用户 → 内核 socket 缓冲区(write) 4️⃣ DMA:socket 缓冲区 → 网卡
期间发生了四次拷贝,也就是四次上下文切换。他的问题在于CPU 参与两次数据拷贝,需要在用户态和内核态来回切换。大文件传输时 CPU 和内存带宽浪费严重
零拷贝不是“完全没有拷贝”,而是:
❗ 不经过用户空间拷贝
比如sendfile命令,他就将数据的传输路径变成了
磁盘 → 内核页缓存 → socket 缓冲区 → 网卡