Skip to content

深度解析:进程、线程与协程——现代并发编程的核心基石

在现代计算机系统中,为了充分利用硬件资源、提高程序响应速度和系统吞吐量,并发编程已成为不可或缺的技术。

而理解并发编程的基石,便是深入掌握进程(Process)、*线程(Thread)协程(Coroutine)*这三大核心概念。

它们代表了不同粒度的并发执行单元,各自在资源管理、调度方式和应用场景上有着显著的区别。

一、进程:资源分配和调度的基本单位

1.1、定义与本质

进程是操作系统进行资源分配和调度的基本单位,是程序的一次动态执行过程。

与存储在磁盘上的静态程序代码不同,进程是程序在内存中的一个活动实例,它拥有独立的内存空间、系统资源(如文件句柄、网络连接等)以及执行上下文(如寄存器值、程序计数器等)。

当用户启动一个应用程序时,操作系统就会为其创建一个或多个进程。

1.2、进程的组成与内存布局

一个进程实体通常由三部分组成:

  • 程序段:存储着进程要执行的机器指令。

  • 数据段:存储着程序运行时所需的变量、常量和文件缓冲区等数据。

  • 进程控制块:这是操作系统管理和控制进程的核心数据结构。

    PCB包含了进程的唯一标识符(PID)、进程状态、程序计数器、寄存器值、内存管理信息、I/O状态信息、调度信息等。

    操作系统正是通过PCB来感知和管理每一个进程。

除了上述逻辑组成,从内存角度看,每个进程都拥有独立的虚拟地址空间

典型的Linux进程内存布局如下所示:

进程虚拟地址空间布局

  • 代码段(Text Segment):存放可执行文件的机器指令,通常是只读的。
  • 数据段(Data Segment):存放已初始化的全局变量和静态变量。
  • BSS段(Block Started by Symbol Segment):存放未初始化的全局变量和静态变量,在程序启动时由系统清零。
  • 堆(Heap):用于动态内存分配,如C/C++中的malloc/freenew/delete。堆向上增长。
  • 内存映射段(Memory Mapping Segment):用于文件映射、匿名映射以及动态库加载等。堆和栈之间的区域。
  • 栈(Stack):用于存放局部变量、函数参数、返回地址等。栈向下增长,由编译器自动管理。
  • 内核空间(Kernel Space):位于虚拟地址空间的最高部分,用于操作系统内核代码和数据。用户进程无法直接访问。
虚拟内存与物理内存的映射

每个进程都拥有独立的虚拟地址空间,这使得进程可以认为自己独占了整个内存。操作系统通过页表(Page Table)将虚拟地址映射到物理地址。

当进程访问一个虚拟地址时,MMU(Memory Management Unit)会根据页表进行地址转换。如果访问的页面不在物理内存中,会触发缺页中断(Page Fault),操作系统会将该页面从磁盘加载到物理内存中。

这种机制实现了内存的隔离和按需加载,提高了内存利用率。

1.3、进程的特征

进程具有五个显著特征:

  • 动态性:进程是程序的一次执行过程,具有生命周期,从创建、运行到最终消亡。
  • 并发性:多个进程实体可以同时存在于内存中,并在宏观上并发执行,即使在单核CPU上也能通过时间片轮转实现。
  • 独立性:进程是独立运行、独立获取资源、独立接受调度的基本单位。每个进程的内存空间相互隔离,互不干扰,保证了系统的稳定性和安全性。
  • 异步性:各进程以各自独立的、不可预知的速度向前推进。操作系统需要提供进程同步机制来解决因异步性可能导致的数据不一致问题。
  • 结构性:每个进程都由程序段、数据段和PCB组成。

1.4、进程的创建与终止

创建

在Linux系统中,进程的创建主要通过fork()系统调用实现。

fork()会创建一个当前进程的子进程,子进程是父进程的精确副本,拥有父进程的几乎所有资源,包括内存空间、文件描述符等。

然而,为了提高效率,现代操作系统通常采用写时复制(Copy-on-Write, COW)机制:父子进程最初共享相同的物理内存页,只有当任

一进程尝试修改这些共享页面时,操作系统才会为修改方复制一份独立的副本。

exec()系列系统调用则用于在当前进程的上下文中加载并执行一个新的程序。

exec()被调用时,原进程的地址空间会被新程序的代码和数据覆盖,但进程ID(PID)保持不变。

forkexec通常结合使用,先fork创建子进程,然后在子进程中exec加载新的程序。

终止

进程的终止通常由exit()系统调用触发,或者因异常(如段错误)而终止。

操作系统会回收进程占用的所有资源,并更新其PCB状态。

当子进程终止而父进程尚未调用wait()waitpid()来获取子进程的终止状态时,子进程会成为僵尸进程,它会保留PCB信息直到父进程处理。如果父进程在子进程之前终止,子进程会成为孤儿进程,并被init进程(PID为1)收养,由init进程负责其资源的回收。

1.5、进程的生命周期与状态转换

进程在其生命周期中会经历多种状态,并在特定条件下进行转换。典型的进程状态包括创建态、就绪态、运行态、阻塞态和终止态。

进程状态转换图

  • 创建态:进程正在被创建,操作系统为其分配资源、初始化PCB。
  • 就绪态:进程已准备好运行,只等待CPU调度执行。
  • 运行态:进程正在CPU上执行指令。
  • 阻塞态:进程因等待某个事件(如I/O操作完成、等待资源等)而暂停执行,主动放弃 CPU。
  • 终止态:进程执行完毕或异常退出,操作系统回收其资源。

进程的这些状态转换由操作系统内核通过原语操作进行控制,原语是不可中断的原子操作,确保了进程控制的正确性。

1.6、进程调度算法

操作系统采用各种调度算法来决定哪个就绪进程应该获得CPU执行权。

调度算法的目标通常是最大化CPU利用率、最大化吞吐量、最小化周转时间、最小化等待时间、最小化响应时间等。

常见的调度算法包括 :

  • 先来先服务(FCFS, First-Come, First-Served):按照进程到达就绪队列的顺序进行调度。简单易实现,但对短进程不利。
  • 短作业优先(SJF, Shortest Job First):优先调度预计运行时间最短的进程。可以最小化平均等待时间,但需要预估运行时间,且可能导致长作业饥饿。
  • 优先级调度(Priority Scheduling):为每个进程分配一个优先级,优先调度优先级最高的进程。可分为抢占式和非抢占式。高优先级进程可能导致低优先级进程饥饿,通常需要引入老化(Aging)机制来解决。
  • 时间片轮转(Round Robin, RR):为每个进程分配一个固定的时间片,进程用完时间片后被剥夺CPU,放到就绪队列末尾等待下一轮调度。适用于分时系统,响应时间较好,但时间片大小的选择至关重要。
  • 多级反馈队列(Multilevel Feedback Queue, MLFQ):结合了时间片轮转和优先级调度,将进程分为多个队列,每个队列有不同的优先级和时间片大小,以兼顾响应时间和吞吐量。新到达的进程通常进入最高优先级队列,如果时间片用完仍未完成,则降级到较低优先级队列。这是现代操作系统(如Linux)中复杂调度器的基础。
  • Linux CFS (Completely Fair Scheduler):Linux 2.6.23版本引入的调度器,旨在提供公平的CPU时间分配。它不使用固定的时间片,而是根据进程的“虚拟运行时”(vruntime)来调度,vruntime最小的进程优先运行,从而实现“完全公平”。

1.7、进程间通信(IPC)

由于进程之间内存空间相互隔离,它们需要特定的机制来进行数据交换和协作,这被称为进程间通信(Inter-Process Communication, IPC)。

常见的IPC机制包括:

  • 管道(Pipe):半双工通信,通常用于父子进程之间。分为匿名管道(无名管道)和命名管道(FIFO)。匿名管道只能用于有亲缘关系的进程,命名管道可以用于任意进程。
  • 消息队列(Message Queue):进程通过发送和接收消息进行通信,克服了管道的局限性。消息队列是存储在内核中的消息链表,允许异步通信,但存在消息拷贝开销。
  • 共享内存(Shared Memory):允许不同进程访问同一块物理内存,是最快的IPC方式,但需要同步机制来避免数据竞争。进程将同一块物理内存映射到各自的虚拟地址空间中。
  • 信号量(Semaphore):用于进程间的同步和互斥。它是一个计数器,用于控制对共享资源的访问数量。通常与共享内存配合使用。
  • 套接字(Socket):可用于不同主机或同一主机上不同进程间的通信。是网络通信的基础,也支持本地进程间通信(Unix域套接字)。
  • 信号(Signal):一种异步通知机制,用于通知进程发生了某个事件,如Ctrl+C发送的SIGINT信号。

二、线程:CPU调度的最小单位

2.1、定义与本质

线程是进程内的执行单元,是CPU调度和分派的基本单位。一个进程可以包含一个或多个线程。

与进程不同,线程共享其所属进程的内存空间和大部分资源(如文件句柄、代码段、数据段等),但每个线程拥有独立的程序计数器、寄

存器集合和栈。

线程的引入旨在提高程序的并发度,减少进程切换的开销。

2.2、线程的组成与内存模型

每个线程拥有独立的私有资源,主要包括:

  • 线程ID (Thread ID):唯一标识线程。
  • 程序计数器 (Program Counter, PC):指向下一条要执行的指令地址。
  • 寄存器集合 (Registers):存储CPU在执行线程时所需的数据,如通用寄存器、状态寄存器等。
  • 栈 (Stack):存储局部变量、函数参数、返回地址等,每个线程有独立的栈空间。线程栈的大小通常是固定的,但可以通过编程进行配置。
  • 线程局部存储(Thread Local Storage, TLS):允许每个线程拥有自己独立的变量副本,即使这些变量在代码中被声明为全局或静态。这避免了线程间共享数据可能带来的同步问题。

而线程与同进程内的其他线程共享以下资源:

  • 进程的地址空间(代码段、数据段、堆、内存映射段)
  • 文件描述符表
  • 信号处理函数
  • 进程的用户ID和组ID

下图展示了线程的内部结构及其与进程内存空间的共享关系:

线程内部结构示意图

2.3、线程的生命周期与状态

线程在其生命周期中也会经历创建、就绪、运行、阻塞和终止等状态,与进程的状态转换类似,但粒度更细,且发生在进程内部。

线程的状态转换通常由线程库(如Pthreads)或操作系统内核管理。

2.4 线程的优势

  • 提高并发度:在一个进程中可以同时执行多个任务,例如,一个视频播放器可以使用一个线程加载视频数据,另一个线程负责视频渲染,从而避免卡顿。
  • 资源共享:线程共享进程的资源,使得线程间通信比进程间通信更高效、更便捷,通常通过直接读写共享内存实现。
  • 上下文切换开销小:线程的上下文切换只涉及少量寄存器和栈的保存与恢复,远小于进程上下文切换的开销,因此切换速度更快。

2.5、线程的挑战

  • 资源竞争与同步:由于线程共享进程的内存空间,当多个线程同时访问和修改同一共享资源时,可能导致数据不一致或竞态条件。这需要引入锁(Lock)、信号量(Semaphore)、互斥量(Mutex)等同步机制来保证数据安全和操作的原子性。
  • 死锁:不当的同步机制可能导致多个线程相互等待对方释放资源,从而陷入永久等待状态,即死锁。
  • 调试复杂性:多线程程序的执行顺序不确定,难以复现和调试并发问题。

2.6、用户级线程与内核级线程模型

根据线程的实现和管理方式,线程可以分为用户级线程和内核级线程,以及它们组合而成的模型:

用户级线程

线程的创建、调度和管理完全由用户空间的线程库完成,操作系统内核对此一无所知。

内核只知道进程,不知道进程内部的用户级线程。

优点:切换开销极小,无需内核态/用户态切换;

缺点:当一个用户级线程阻塞时,整个进程都会阻塞,因为内核会将整个进程挂起。

此外,无法利用多核CPU的并行性。

内核级线程

线程的创建、调度和管理由操作系统内核完成。

每个用户线程都有一个对应的内核线程。

优点:当一个线程阻塞时,其他线程可以继续执行,且可以利用多核CPU实现真正的并行;

缺点:线程切换需要陷入内核态,开销相对较大。

线程模型
  • 多对一模型:多个用户级线程映射到一个内核级线程。

    优点是线程管理开销小‘;

    缺点是并发度受限,一个线程阻塞会导致整个进程阻塞,且无法利用多核CPU。

  • 一对一模型:一个用户级线程映射到一个内核级线程。这是现代操作系统(如Linux、Windows)大多采用的模型。

    优点是并发度高,一个线程阻塞不影响其他线程,且可以利用多核CPU;

    缺点是创建线程的开销相对较大,因为每个用户线程都需要一个对应的内核线程,系统对线程数量的限制也更严格。

  • 多对多模型:多个用户级线程映射到较少或等量的内核级线程。

    结合了前两者的优点,既能实现用户态的轻量级管理,又能利用多核CPU并行执行。

    当一个用户级线程阻塞时,可以将其他用户级线程调度到另一个可用的内核级线程上执行。但实现复杂。

2.7、线程同步机制

为了解决多线程环境下的数据竞争问题,需要使用线程同步机制。除了前面提到的基本机制,还需要深入理解其工作原理和适用场景:

  • 互斥锁(Mutex):

    • 原理:通过原子操作(如test-and-set指令)来保证对共享资源的独占访问。

      当一个线程成功获取互斥锁后,它进入临界区;其他试图获取该锁的线程将被阻塞,直到锁被释放。

      互斥锁通常有两种状态:锁定和解锁。

    • 类型:递归锁(允许同一线程多次获取)、非递归锁、尝试锁(非阻塞获取)。

    • 应用:保护共享数据结构,确保每次只有一个线程修改数据。

  • 信号量(Semaphore):

    • 原理:一个非负整数计数器,用于控制对共享资源的访问数量。

      P操作(wait)使计数器减1,如果计数器为负则阻塞;V操作(signal)使计数器加1,如果计数器小于等于0则唤醒一个等待线程。

      互斥锁可以看作是二元信号量(计数器为1)。

    • 应用:控制对有限资源的访问(如数据库连接池),实现生产者-消费者模型。

  • 条件变量(Condition Variable):

    • 原理:总是与互斥锁配合使用。

      它允许线程在某个条件不满足时,原子性地释放互斥锁并进入等待状态;当其他线程改变条件并通知条件变量时,等待线程被唤醒并重新尝试获取互斥锁。

      主要用于线程间的协作和等待特定条件。

    • 应用:生产者-消费者模型、线程间事件通知。

  • 读写锁(Read-Write Lock):

    • 原理:允许多个读线程同时持有锁,但只允许一个写线程持有锁。

      当写锁被持有时,所有读写请求都会被阻塞。当读锁被持有且没有写锁请求时,新的读锁请求可以立即获得。

      这种机制在读操作远多于写操作的场景下能显著提高并发性能。

    • 应用:缓存系统、共享配置读取。

  • 原子操作(Atomic Operations):

    • 原理:利用CPU提供的原子指令(如CAS - Compare And Swap)来对共享变量进行操作,保证操作的不可中断性。

      无需使用传统的锁机制,避免了锁带来的开销和死锁风险。

    • 应用:计数器、无锁数据结构。

死锁的产生与避免:

产生死锁的四个必要条件(霍尔德条件)是:

  1. 互斥条件:资源不能共享,一次只能被一个线程使用。
  2. 请求与保持条件:线程已经获得至少一个资源,但又请求新的资源,而该资源已被其他线程占有,此时不释放已占有的资源。
  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被强行剥夺。
  4. 循环等待条件:存在一个线程链,每个线程都在等待链中下一个线程所占有的资源。

避免死锁的策略通常是破坏其中一个或多个条件:

  • 破坏请求与保持条件:一次性申请所有需要的资源,或者在申请新资源前释放所有已占有的资源。
  • 破坏不剥夺条件:允许操作系统剥夺已占有资源的线程。
  • 破坏循环等待条件:对所有资源进行排序,并规定线程只能按序申请资源。

2.8、全局解释器锁(GIL)

全局解释器锁(Global Interpreter Lock, GIL)是Python解释器(CPython)的一个特性,它确保在任何给定时刻,只有一个线程在执行Python字节码。

这意味着,即使在多核CPU系统上,Python的多线程程序也无法实现真正的并行计算。

GIL的存在是为了简化Python解释器内部的内存管理和避免复杂的线程同步问题。

然而,它对CPU密集型任务的性能产生了显著影响:多线程反而可能因为GIL的竞争和频繁的上下文切换而导致性能下降。

对于I/O密集型任务,GIL的影响相对较小,因为当一个线程执行I/O操作时,它会主动释放GIL,允许其他线程运行。

因此,Python多线程在处理网络请求、文件读写等I/O操作时仍然能有效提高并发性。

为了在Python中实现真正的并行,通常需要使用多进程(每个进程有自己的Python解释器和GIL)或利用C/C++扩展来执行CPU密集型部分,或者采用异步编程(协程)来高效处理I/O密集型任务。

2.9、线程池的实现与管理

线程池(Thread Pool)是一种管理和复用线程的机制,旨在减少线程创建和销毁的开销,提高系统性能和资源利用率。

一个典型的线程池包含以下组件:

  • 任务队列(Task Queue):用于存放待执行的任务。当有新任务到来时,将其放入任务队列。
  • 工作线程(Worker Threads):线程池中预先创建的线程,它们不断从任务队列中取出任务并执行。当任务队列为空时,工作线程通常会进入等待状态。
  • 线程管理器(Thread Manager):负责线程池的创建、销毁、扩容和缩容等管理工作。

线程池的优势:

  • 降低资源消耗:避免了频繁创建和销毁线程的开销。
  • 提高响应速度:任务无需等待线程创建即可立即执行。
  • 提高线程可管理性:可以限制并发线程的数量,避免系统过载。

线程池的参数:

  • 核心线程数(Core Pool Size):线程池中始终保持的线程数量。
  • 最大线程数(Maximum Pool Size):线程池允许创建的最大线程数量。
  • 任务队列容量:任务队列可以容纳的最大任务数量。
  • 线程存活时间(Keep-Alive Time):当线程数超过核心线程数时,空闲线程在终止前等待新任务的最长时间。

三、协程:用户态的轻量级并发

3.1、定义与本质

协程(Coroutine)是一种比线程更轻量级的并发执行单元,它被称为“用户态线程”、“微线程”或“纤程(Fiber)” 。

与进程和线程由操作系统进行抢占式调度不同,协程的调度完全由程序自身控制,是协作式调度。

当一个协程遇到I/O操作或需要暂停时,它会主动让出CPU控制权,并保存当前执行状态,以便在适当的时候恢复执行。

这种“主动让出”的特性是协程与线程最本质的区别。

3.2、协程的优势

  • 极低的上下文切换开销:协程的切换发生在用户态,无需陷入内核,不涉及CPU寄存器、栈、虚拟内存等复杂状态的保存和恢复,因此切换开销极小,速度非常快。
  • 高效的I/O密集型任务处理:协程在等待I/O操作(如网络请求、文件读写)时,可以挂起当前任务,转而执行其他协程,从而充分利用CPU时间,避免CPU空闲等待,显著提高I/O密集型应用的响应速度和吞吐量。
  • 编程模型简洁:通过async/await等语法糖,协程使得异步代码的编写更接近同步代码的逻辑,提高了代码的可读性和可维护性,避免了传统回调函数带来的“回调地狱”问题。
  • 单线程内并发:协程在单个线程内实现并发,避免了多线程带来的复杂锁机制和数据竞争问题,简化了并发编程的复杂度 [4]。

3.3、协程的局限性

  • 无法并行执行CPU密集型任务:协程本质上是单线程的协作式并发,不能利用多核CPU的并行计算能力。对于CPU密集型任务,一个长时间运行的协程会阻塞整个线程,导致其他协程无法执行。
  • 需要语言或框架支持:协程的实现依赖于编程语言或特定的协程库(如Python的asyncio,C++20的协程),不像进程和线程那样由操作系统原生支持。
  • “传染性”:一旦引入协程,相关的函数和库都需要适配异步/协程的编程范式,否则难以发挥其优势。

3.4、协程的实现方式:Stackful vs Stackless

协程的实现方式主要分为两种:

Stackful Coroutines(有栈协程)

每个协程拥有独立的栈空间。当协程切换时,需要保存和恢复整个栈的状态。

这种实现方式功能强大,可以支持任意位置的暂停和恢复,但实现复杂,且每个协程的内存开销相对较大。

例如,Go语言的Goroutine就是有栈协程。

Stackless Coroutines(无栈协程)

协程不拥有独立的栈空间,而是通过编译器将协程的状态(局部变量、程序计数器等)封装在一个状态机中。

当协程暂停时,状态机保存当前状态;当协程恢复时,状态机加载状态并从上次暂停的地方继续执行。

这种实现方式内存开销小,实现相对简单,但通常对协程的暂停点有严格限制(如只能在await关键字处暂停)。

Python的asyncio协程和C++20的协程都属于无栈协程。

3.5、协程调度器与事件循环

协程的协作式调度需要一个协程调度器(Coroutine Scheduler)或事件循环(Event Loop)来管理。

事件循环是异步编程的核心,它负责监听I/O事件、管理协程的生命周期、在协程之间进行切换。

当一个协程执行到I/O操作时,它会向事件循环注册一个回调,然后主动挂起,将控制权交还给事件循环。

事件循环会继续执行其他就绪的协程,直到I/O操作完成,此时事件循环会唤醒之前挂起的协程,使其恢复执行。

这种机制使得单线程能够高效地处理大量并发I/O。

协程切换流程图

3.5.1、事件循环的工作原理

事件循环是异步编程模型的核心,其基本工作流程如下:

  1. 初始化:事件循环启动时,会初始化一个任务队列(通常是优先队列或双端队列)和一个I/O多路复用器(如Linux的epoll)。
  2. 注册任务:当一个协程(或异步任务)被创建并准备执行时,它会被添加到事件循环的任务队列中。
  3. 循环执行:事件循环进入一个无限循环:
    • 执行就绪任务:从任务队列中取出当前就绪的协程并执行。协程会一直运行直到遇到await表达式(表示等待一个异步操作完成)或主动让出控制权。
    • 处理I/O事件:当协程遇到await一个I/O操作时,它会将I/O操作注册到I/O多路复用器上,然后挂起自身,将控制权交还给事件循环。事件循环此时会检查I/O多路复用器,看是否有已完成的I/O事件。
    • 唤醒协程:一旦I/O多路复用器通知某个I/O操作已完成,事件循环会找到对应的挂起协程,将其重新标记为就绪状态,并放入任务队列等待下一次调度。
    • 定时任务:事件循环还会处理定时任务,例如sleeptimeout等。

通过这种机制,单个线程可以在等待I/O时“切换”到其他任务,从而在宏观上实现并发,而无需进行昂贵的线程上下文切换。

3.5.2、异步I/O模型

协程的高效I/O处理能力离不开底层的异步I/O模型。

传统的同步阻塞I/O模型中,当应用程序发起一个I/O请求时,会一直等待直到I/O操作完成,期间CPU处于空闲状态。

而异步I/O模型则允许应用程序在发起I/O请求后立即返回,继续执行其他任务,当I/O操作完成后,操作系统会通知应用程序。

常见的异步I/O模型包括:

  • 非阻塞I/O(Non-blocking I/O):应用程序发起I/O请求后,如果数据未准备好,系统调用会立即返回一个错误(如EAGAINEWOULDBLOCK)。应用程序需要不断轮询(poll)I/O状态,直到数据准备就绪。这种方式需要应用程序主动管理轮询,效率较低。

  • I/O多路复用(I/O Multiplexing):通过一个系统调用(如select, poll, epoll)同时监听多个文件描述符(I/O通道)的事件。

    当任何一个文件描述符上的事件发生时,系统调用返回,应用程序可以根据返回的事件类型处理相应的I/O。这是构建事件循环的基础,它允许单个线程同时管理大量并发连接,而无需为每个连接创建一个线程。

    • select:最早的I/O多路复用机制,限制了文件描述符的数量,且每次调用都需要将所有文件描述符从用户空间拷贝到内核空间,效率较低。
    • poll:解决了select的文件描述符数量限制,但仍然存在大量拷贝开销。
    • epoll (Linux):更高效的I/O多路复用机制。它们采用事件驱动的方式,只在文件描述符状态发生变化时通知应用程序,避免了轮询,并且在内核中维护事件列表,减少了拷贝开销。这是现代高性能网络服务器(如Nginx、Redis)和异步框架(如asyncio、Node.js)的核心。
  • 异步I/O(Asynchronous I/O, AIO):应用程序发起I/O请求后,立即返回,当I/O操作完全完成后,操作系统会通知应用程序(通过信号或回调函数)。

    与非阻塞I/O的区别在于,AIO是真正的异步,应用程序无需轮询,I/O操作在后台由内核完成。

    但AIO的实现通常比I/O多路复用更复杂,且在不同操作系统上的支持程度不一。

协程通常结合I/O多路复用技术来实现高效的异步I/O。当协程执行await一个网络请求时,它会将socket注册到epoll等I/O多路复用器上,然后挂起。事件循环继续运行,当epoll通知socket可读/可写时,事件循环唤醒对应的协程,使其继续处理数据。

四、进程、线程与协程的对比

特性进程(Process)线程(Thread)协程(Coroutine)
定义操作系统资源分配和调度的基本单位进程内CPU调度和分派的基本单位用户态的轻量级并发执行单元
资源拥有独立的内存空间和系统资源共享进程的内存空间和大部分资源,拥有独立的栈、寄存器共享线程的内存空间和资源,拥有独立的栈、寄存器,但更轻量
调度操作系统进行抢占式调度操作系统进行抢占式调度程序(用户)进行协作式调度
切换开销重量级,上下文切换开销大中量级,上下文切换开销中等极轻量级,上下文切换开销极小
通信方式进程间通信(IPC):管道、消息队列、共享内存等共享内存、锁、信号量等同步机制直接通过函数调用或yield传递数据,无需额外同步机制
并行性可实现真正的并行(多核CPU)可实现真正的并行(多核CPU)单线程内协作,无法实现真正的并行(CPU密集型)
稳定性独立性强,一个进程崩溃不影响其他进程一个线程崩溃可能导致整个进程崩溃一个协程异常通常只影响当前协程,但可能影响整个线程
适用场景CPU密集型任务、多程序隔离、安全性要求高CPU密集型任务、I/O密集型任务(受GIL影响)、多核利用I/O密集型任务、高并发网络服务、异步编程

进程、线程、协程关系图

五、并发模型的选择

在实际开发中,选择合适的并发模型至关重要。通常取决于任务的性质(CPU密集型或I/O密集型)、系统资源(单核或多核CPU)以及对编程复杂度的考量。

CPU密集型任务

这类任务需要大量的计算,如科学计算、图像处理、数据分析等。

此时,多进程是最佳选择,因为它充分利用多核CPU的并行计算能力,每个进程独立运行,互不干扰。

多线程也可以用于CPU密集型任务,但在某些语言(如Python,受GIL限制)中,多线程并不能真正并行执行CPU密集型任务,此时多进程更具优势。

I/O密集型任务

这类任务涉及大量的输入/输出操作,如网络请求、数据库查询、文件读写等,计算量相对较小,大部分时间都在等待I/O完成。

协程在这种场景下表现出色,它可以在等待I/O时切换到其他任务,极大地提高了资源利用率和系统吞吐量。

多线程也可以处理I/O密集型任务,但其上下文切换开销相对较大,且可能引入复杂的同步问题。

组合使用策略:

在高性能系统中,常常会结合使用多种并发模型以发挥各自优势:

  • 多进程 + 多线程:例如,一个Web服务器可能采用多进程模型(每个进程绑定一个CPU核心),每个进程内部再使用多线程来处理并发请求。这样既能利用多核并行,又能通过线程提高进程内部的并发度。
  • 多进程 + 协程:在Python等受GIL限制的语言中,可以采用多进程来利用多核CPU,每个进程内部再使用协程来高效处理I/O密集型任务。这是实现高性能异步Web服务(如基于uvicornFastAPI)的常见模式。
  • 多线程 + 协程:在Go语言等原生支持协程的语言中,Goroutine(协程)运行在少量操作系统线程之上,由Go运行时(Runtime)负责调度。这种模型既能享受协程的轻量级和高效I/O,又能通过底层线程池利用多核CPU。

六、总结

  • 进程提供了最强的隔离性,是资源分配的基本单位,适用于需要独立资源和高安全性的场景,尤其在CPU密集型任务中能充分利用多核优势。
  • 线程是CPU调度的基本单位,共享进程资源,上下文切换开销小于进程,适用于需要共享数据和较高并发度的场景,但需要谨慎处理同步问题。
  • 协程是用户态的轻量级并发,通过协作式调度实现极低的切换开销,是处理I/O密集型任务和构建高并发、高吞吐量系统的理想选择,尤其在现代异步编程框架中扮演核心角色。