Operating System

进程与线程

线程和进程有什么区别?

processThreadOperating System

线程和进程有什么区别?

进程是操作系统分配资源的最小单位,线程是 CPU 调度的最小单位

每个进程有自己独立的内存空间,包括代码段、数据段、堆等,可以看作是一个正在运行的程序实例,且各进程之间相互独立。

线程属于进程,一个进程可以包含多个线程。线程共享进程的内存空间和资源,比如文件句柄、数据段,但每个线程有自己独立的栈和寄存器。

线程共享进程内的:

  • 代码段,所有线程执行的是同一份程序代码。
  • 全局变量,多个线程访问的是同一块内存。这也是为什么会有线程安全问题。
  • 堆内存
  • 文件描述符,线程共享进程打开的文件,创建的socket连接等

线程私有的部分:程序计数器,虚拟机栈,寄存器等

因此线程和进程的区别主要在四个地方:

  1. 内存空间
    • 进程之间是隔离的,就像两个车间,墙是封死的,数据不能随便乱串。线程之间是共享的,同一个车间里的工人可以共用堆内存、代码段和全局变量,只有栈空间和寄存器是私有的。
  2. 创建和切换的成本
    • 进程的创建需要分配各种资源,比如linux里的fork方法,会分配PID,复制页表结构,建立虚拟地址空间,复制文件描述符等等,而线程的创建不需要复制页表,也不需要创建新的内存管理结构。
    • 进程的切换需要切换程序计数器,cpu寄存器,页表,栈等,并且还会刷新页表缓存,也就是TLB导致缓存失效,而线程之需要保存寄存器,程序计数器,切换栈即可
  3. 通信方式
    • 进程间通信比较麻烦,因为隔着墙,得用管道、消息队列或者共享内存等。线程间通信非常简单,因为大家都在一个屋檐下,直接读写共享变量就行,但要注意同步问题,防止两个线程同时抢一个资源出问题。
  4. 稳定性
    • 进程更安全,一个进程崩溃不会影响其他进程,因为进程间相互独立,其他进程还能照常跑。线程风险大,一个线程崩了,可能导致其他线程也会受影响,进而导致进程崩溃。

上下文切换到底切了什么?

CPU 执行代码时,寄存器里存着当前的执行状态:程序计数器指向下一条指令,栈指针指向当前栈顶,还有一堆通用寄存器存着中间计算结果。

线程切换时,内核要把这些寄存器的值保存到内存里的 task_struct 中,然后把另一个线程保存的值恢复到寄存器。这个过程本身不慢,几微秒的事。

真正慢的是缓存失效

CPU 有 L1、L2、L3 缓存,切换线程后,新线程访问的数据大概率不在缓存里,要从内存重新加载,这比访问缓存慢 100 倍。进程切换更惨,还要切换页表,TLB 也会失效。

所以高性能服务器都尽量减少线程切换。Nginx 用少量 worker 进程,每个进程单线程跑事件循环。Netty 也是类似思路,用少量线程配合 epoll 处理海量连接。

切换类型需要保存/恢复的内容额外开销
协程切换少量寄存器、栈指针
线程切换全部寄存器内核态切换、缓存可能失效
进程切换全部寄存器内核态切换、页表切换、TLB 失效

为什么说线程共享堆但不共享栈?

原因在于,堆是用来存动态分配的对象的,整个进程就一个堆,所有线程 new 出来的对象都在里面,所以天然共享。

栈是用来存函数调用链和局部变量的,每个线程的调用路径不一样,执行到哪一步也不一样,必须各自有独立的栈。并且由于线程执行任务需要获得cpu时间片,时间片结束没执行完需要保存当前寄存器,并在下次获得时间片时恢复寄存器继续执行。如果共享栈,一个线程调用函数把栈顶指针改了,另一个线程就完全乱套了。

进程间的通信方式

  1. 管道 是最简单的 IPC 方式,匿名管道用于亲缘关系的父子或兄弟进程间通信。命名管道遵循先进先出,以磁盘文件方式存在,可实现本机任意两个进程通信。
  2. 信号 (Signal): 通知接收进程某个事件已经发生
  3. 消息队列(Message Queuing): 消息队列是消息链表,有特定格式,存于内存,由标识符标识。管道和消息队列通信数据皆遵循先进先出原则。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取。
  4. 消息量 (Semaphores): 信号量是一个计数器,用于多进程对共享数据的访问,用于进程间同步。解决与同步相关的问题并且避免了竞争条件。
  5. 共享内存(Shared memory): 最快的 IPC 方式,使得多个进程可以访问同一块内存空间,及时看到别的进程对于共享内存中的数据更新
  6. 套接字 (Sockets): 最灵活的IPC方式,主要用于再客户端和服务器之间通过网络进行通信。套接字支持 TCP/IP 的网络通信的基本操作单一,可以看做是不同主机之间的进程进行双向通信的端点。

管道

管道实质是一个内核缓冲区,进程以先进先出的方式从缓冲区中存取数据。每次写数据需要将用户态的内容拷贝到内核态,取数据也需要从内核态拷贝到用户态。

传输的数据是字节流,因此不具备边界。

匿名管道

匿名管道没有路径、没有名字、没有标识符。在创建时会返回两个 fd,分别表示读和写,只有这两个 fd 能访问它。

但由于其他进程拿到不到这两个fd,而主进程fork出的子进程会共享主进程的fd资源,因此匿名管道只有具备亲缘关系的进程才能使用,并且是单向的

命名管道

支持双向通信通过命令mkfifo创建,如果两个进程open的有名管道路径一致,就能进行通信。但通信效率很低


共享内存

管道需要数据在进程和内核之间进行拷贝,共享内存不一样,多个进程的虚拟地址映射到同一块物理内存。进程 A 写了个数据,进程 B 立刻就能看到,压根不需要内核介入,也不需要数据拷贝。因此可以说共享内存是零拷贝的 IPC。

但共享内存有个大坑:内核不管同步

两个进程同时写同一块内存,数据就乱套了。所以实际使用必须配合信号量或者互斥锁。

共享内存的使用流程:

  1. 进程A调用 shm_open 创建共享内存对象
  2. 进程A调用 ftruncate 设置共享内存大小
  3. 进程A调用 mmap 将共享内存映射到自己的地址空间
  4. 进程B调用 shm_open 打开同一个共享内存对象
  5. 进程B调用 mmap 将共享内存映射到自己的地址空间
  6. 两个进程通过映射的地址直接读写同一块物理内存

消息队列

管道是字节流,没有边界的概念,写 100 字节可能分 3 次读完。消息队列是有边界的,一条消息要么读完要么不读,不会读一半。

消息队列还支持按类型读取。比如进程 A 发类型为 1 的消息,进程 B 发类型为 2 的消息,接收方可以指定只读类型 1 的消息,其他的留在队列里。


Socket套接字

实现跨网络与不同主机上的进程之间通信,需要用到 Socket通信。凭借这种机制,客户端/服务器系统的开发工作既可以在本地单机上进行,也可以跨网络进行。

  1. 服务端和客户端初始化 socket 得文件描述符。
  2. 服务端 bind 绑定 IP 和端口,listen 监听,accept 等客户端连接。
  3. 客户端 connect 向服务端地址和端口发起连接请求。
  4. 服务端 accept 返回传输 socket 文件描述符。
  5. 客户端 write 写入数据,服务端 read 读取。
  6. 客户端 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完成,等信号量,或者是等锁释放
  • 终止态:执行完毕或者因为某些原因被终止,进程结束,资源被回收。

进程状态转换流程

  1. 新建态完成初始化后进入就绪态
  2. 就绪态被 CPU 调度后进入运行态
  3. 运行态时间片用完或被抢占回到就绪态
  4. 运行态等待 I/O 或事件进入阻塞态
  5. 阻塞态等待的事件完成后回到就绪态
  6. 运行态执行完毕或异常退出进入终止态

状态转换的触发条件

就绪 → 运行: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 缓冲区 → 网卡


post.comments