进程·线程·协程初探

Start

这里是一些自己对进程线程以及协程的学习记录和思考,如有错误还望指正。

进程,线程,协程这三者可以说是计算机领域,亦或者是面试题中最常遇到的部分了,在许多次的面试中多多少少回答了一些内容,但是大多都不是系统性的,且缺乏一定的深度。因此有了这篇文章,算是努力写一篇较为系统的学习笔记吧。希望下次面试的时候我能记起写在这里的内容,悲......

基础概念

在现代操作系统中(例如windows,linux等基于分时系统设计的操作系统),常有的观念是

一个程序至少包含一个进程,而一个进程又至少包含一个线程。线程是程序真正的执行单元。

线程与进程的调度控制由操作系统决定。在现如今的多核CPU情况下,多数时候是一个核心可以同时“并行”两个线程。因此常可以听到类似“这个cpu是双核四线程”这种描述。然而需要注意的是这里的“并行”是打引号的。因为实际上CPU的核心只可以顺序执行一系列的机器指令,且在早期的CPU设计中,一个核心同一时刻只可以运行一个线程。

Intel在2022年推出了“超线程(Hyper-Threading)技术”,这一技术的出现使得CPU可以实现“单个核心双线程”。简单的理解这个技术就是单线程时代的CPU实际上在单个时间片内是有处理能力冗余的,因此Intel为单个核心额外添加一小部分资源就可以实现同时运行第二个线程。

到目前为止我们讨论了的是现代基于分时系统设计的操作系统,然而其实在早期的计算机设计中(例如早期的UNIX,linux 2.4以及更早的版本中),进程(Process)才是程序的基本执行单元。(当然那个时候也还没有线程这个概念)

不过实践上,不论是进程Process还是线程Thread都是人们对于计算机科学中计算资源调度组织的一种设计概念,它们也可以换个名字例如Jobs,例如tasks。甚至由于中文的博大精深有些语境下进程和线程会是一个意思。当然人们可能更加愿意约定俗成的使用进程Process,线程Thread来指代它们。

说了这么多似乎感觉遗漏了协程Coroutine?

其实之所以拖到现在是因为协程Coroutine和进程Process以及线程Threading是完全不同的存在。进程和线程在现代操作系统中是能找到设计实现的,而协程则更像是依托于进程和线程这二者构建的基地而实现的一种调度方式。

相较于线程这种“抢占式多任务”由操作系统本身决定切换时机,协程则要求运行中的每一个执行单元定期放弃自己的执行权力,同时告知作业系统可以调度下一个执行单元。

在实际的应用实现中,线程进程是操作系统提供的接口,而协程更多的是编程语言本身对进程线程调度的优化实现(即线程进程的调度系统是操作系统本身,而协程的调度系统是由编程语言本身依靠操作系统进程线程特性实现的)。例如python以及go语言中对于协程的设计。更加具象化一点说:你可以在操作系统里找到进程ID,线程ID但是找不到协程ID.

总结: 在现代计算机中,一个程序至少包含一个进程,一个进程又至少包含 一个线程。线程是操作系统的最小执行单元。协程是依靠进程线程特性而设计出的一种调度方法。

在接下来的文章中为了避免中文语境下的混淆,我们规定进程即Process,线程即Thread, 协程即Coroutine.

进程线程之间的关系

生命周期

在上一篇章中我们简单的讲了进程与线程之间的关系:一个进程至少包含一个线程。但是实际上线程与进程的关系更复杂一些,尤其是对于各种计算机资源,IO,内存等上面。

线程与进程的生命周期类似(因为本质上进程运行的过程中本质是运行进程中的线程)所以他们都具有类似的生命周期:

  1. 创建created
  2. 就绪ready
  3. 运行running
  4. 等待waiting
  5. 终止terminated

进程创建成功created并得到许可admitted后会进入到“就绪ready”状态等待“调度器scheduler”调度指派运行进入到“运行running”状态。在运行状态中也可以通过操作系统“中断interrupt”再次回到“就绪ready”状态。

在运行running过程中可能因为需要IO资源亦或是某些事件等进入“等待waiting”状态,在等待过程中调度器会去执行其他进入“就绪ready”状态的进程。在等待结束后进程又会进入“就绪ready”状态等待调度器调度。当然最终在运行过程中也可以满足某些条件退出exit,并进入最终终止terminated状态。

alt text

内存层面

进程具有自己独立的进程空间,进程空间内包含基本的数据区,代码区,堆空间和栈。进程所拥有的代码,全局变量,以及打开的文件信息IO句柄,动态链接库等。以上都是进程内的线程们所共享的。

在实际的开发过程中,线程的创建往往是定义一个函数体,然后将这个函数体传给操作系统提供的线程创建函数中以运行调度。

所以其实在内存层面来说,线程就像一个函数体一样有自己的内存栈。栈内维护了一系列的局部变量,寄存器,以及返回地址等。这些数据一起存放在整个进程地址空间的栈区。而进程地址空间内其他的区域包括堆区,代码区,数据区也就是我们常说的线程共享的进程内存空间。反应到代码里就是一些全局变量以及手动分配的在堆上的变量。

alt text

由于CPU的指令执行信息实际上是保存在一个寄存器中的,而之前说的每个线程都有自己独立的寄存器,所以操作系统可以很方便的从这个寄存器中得知线程在哪里被暂停,并继续运行。

此外虽然设计上来说线程共享进程的内存空间,但是线程与线程之间彼此独立。但是不代表线程之间就完全无法访问彼此的变量。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void thread(void* var) {
    int* p = (int*)var;
    *p = 2;
}

int main() {
    int a = 1;
    pthread_t tid;

    pthread_create(&tid, NULL, thread, (void*)&a);
    return 0;
}

如上的函数在main函数中定义了一个变量a,并将a的地址传入给子线程并修改了这个变量的内容。

所以线程与线程之间并不是完全隔离的,这点与进程与进程之间的情况截然不同,进程与进程之间的隔离就严格多了,在现代操作系统中还有一些额外的安全设计来禁止进程之间的直接内存访问。例如进程的地址空间实际上都是虚拟地址空间并不代表实际的物理地址,在32位系统下一个进程的(虚拟)内存空间是4G,这些内存空间通过页表映射到实际的物理地址。(当然 64位下虚拟内存空间的大小会大很多,而且起内部的结构也与32位有差异)

线程局部存储

这是一个用于实现线程似有数据的技术 TLS, Thread Local Storage。

  • 存放在该区域的变量是全局变量所有的线程都可以访问。
  • 尽管所有线程访问的似乎是很同一个变量,但是在变量在一个线程访问过程中仅属于该线程,该线程对此变量的修改对其他线程不可见。

举例以下是一段C++ 11标准的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
int a = 1; // 全局变量

void print_a() {
    cout<<a<<endl;
}

void run() {
    ++a;
    print_a();
}

void main() {
    thread t1(run);
    t1.join();

    thread t2(run);
    t2.join();
}

两个线程轮流对全局变量a进行+1操作,其最终输出结果如下

1
2
2
3

但如果对a变量的声明稍加修改为如下形式

1
__thread int a = 1;

其输出结果就会变成如下内容:

1
2
2
2

线程局部存储可以让你使用一个独属于线程的全局变量。该变量可以被所有的线程访问,且在每个线程中都有一个副本,一个线程对变量的修改不会影响其他的线程。

操作系统层面的进程和线程

截止到这一章节为止,之前所讲的所有有关线程和进程的相关内容其实都是理论性质的。在落实到实际的操作系统上,进程上的理念还是和基本一致,进程是线程的非空集合,然而线程设计实现模式又和理论是有一定差距的。同时,在现代linux和windows操作系统上的线程和进程的具体实现又各自略有不同。

在实际操作系统设计中,内存又分为了如下两个模式:

  • 用户态线程

    操作系统在内存用户空间内创建的线程,由用户空间内的线程库进行管理调度,不能直接访问计算机硬件资源与系统资源,通过系统调用去访问具体的计算机资源(例如IO),此时即出现上下文切换,用户态线程切换到内核态,转由操作系统处理调用,当内核态任务完成后,将控制权返回给用户态线程库(即返回用户态)

    相较于内核态线程其不需要系统调度因此开销较小,同时切换速度快。但缺点是无法充分利用多核处理器,因为在内核看来只有一个内核态线程。

    可以简单的理解为不论有多少个用户态线程,实际最终被执行运算时都会被“放入(陷入)”真正实际拥有运算资源内核态线程中。

  • 内核态线程

    内核态线程是由操作系统来进行管理调度,可直接访问硬件与系统资源,因而可以充分利用多处理器,即使一个线程阻塞,其他线程依旧会运行。

    但其缺点也明显,由于涉及系统资源其上下文切换代价大。

  • 总结

    用户态线程在实际运行过程中会被实际映射到内核态的线程最终被执行,这种映射关系被称为“线程模型”,线程模型可能是:

    • 1对多: 一个内核态线程对应多个用户态线程,这个设计的缺点是无法并行
    • 1对1: 一个内核态线程对应一个用户态线程,windows的设计可以近似理解为这种模式。
    • N对M: 多个用户态线程对应多个用户态线程,Linux采用的就是这个模式,当然也会有些进程优先级较高会赋予1对1。

Linux中的线程与进程

  • 进程 在Linux中,系统通过“fork”这一系统调用创建进程,新的进程是其父进程的拷贝,但拥有独立的地址空间,在新的进程中使用exec族函数可以在新进程中运行与父进程不同的程序。

  • 线程 在Linux中线程通过clone系统调用创建内核态线程并由内核负责调度。在实际的程序中,程序调用PThreads等线程库来创建用户态线程,并最终由底层clone创建其对应的内核态线程。

当然也有完全的纯用户态线程库,可以帮助你创建纯用户态管理的线程同时其调度又完全由程序本身管理。

Windows中的线程与进程

与Linux不同的是,Windows并没有特别强调类似Linux中的内核态与用户态的两种线程。在Windows中所有的线程更多是由内核管理的内核线程,程序运行时也是直接使用内核线程。线程的创建也是通过CreateThread函数直接创建内核线程。

与Linux的差异同时还有其调度算法:

  • Windows使用优先级区分线程调度以及时间片机制,可以保证用户程序的高可用性。
  • Linux则使用CFS(完全公平调度算法)以及RT(基于优先级的全枪展示调度算法)来进行线程调度以确保高并发能力。

编程语言中的线程与进程

现如今的众多高级语言中,类似C, C++ 亦或者是Rust以及Java等大多为1:1的线程模型的“绿色线程模型”, 而Golang,Python等则又是不同的设计

C++

C++ 在C++11中添加了操作线程的库thread,提供对线程操作的进一步封装,这个库底层使用Pthread, 采用了1:1的线程模型。

Java

1:1的线程模型最早被称为“绿色线程”

在Java中用户态线程完全由JVM去管理,早期JVM中的线程模型采用的是对对一的设计,即多个用户线程对应一个操作系统线程。但随着操作系统对线程的支持性的完善,目前JVM内核线程实现了1:1的对应实现。即一个Java线程对应一个内核线程。但是Java的线程堆栈大小固定,随着线程数量创建的越来越对有可能最终导致内存溢出。于是在Java19的版本中引入了“虚拟线程”的概念,虚拟线程具有一个动态的堆栈, 可以增大和缩小,这就实现了和操作系统多对多的线程模型。

一对一的线程模型相对而言维护起来较为简单,但是由于每个线程栈信息固定,不利于创建大量线程,且多线程操作会涉及到频繁的系统调用,上下文切换代价高。

Python

Python的线程采用了操作系统的原生线程, python虚拟机使用了一个全局互斥锁(GIL)来互斥线程对Python虚拟机的使用,当一个线程获取GIL的权限之后,其他线程必须等待这个GIL锁释放才可以被运行。因此Python即使是在多核CPU上,python的多线程也会退化为单线程,无法利用好多核的优势。

  • Python协程

在python中协程是一种语言特性,而不是真正的线程。协程程序也会运行在某个实际的操作系统线程上,协程的计算任务调度通过协程自己的调度器控制,从而避免了线程之间的上下文切换,尤其是python线程的GIL锁天与咒缚的问题,从而实现效率上的提升。

Golang

Go语言中的线程模型就相较于其他都有所不同。Go语言并没有实现传统意义上的用户态线程,而是采用了一种更加轻量级的并发原语——协程。但是这个协程又与Python中的协程模型大不相同。

Go语言的实际线程模型可以被理解为N:M多对多的调度模式。

Go语言中有自己实现的调度器GPM:

  • G(Gorountine): 指代一个Go协程,是Go语言中的最小执行单元
  • P(Processer):逻辑处理器,用于执行Go代码,每个P逻辑处理器都有自己的一个本地队列用来存储等待被执行的协程G
  • M(Machine/OS Thread):操作系统内核线程, 负责实际执行P上的协程。

实际的调度过程中,一个M内核线程可以绑定一个P(Go语言的逻辑处理器)来执行多个G。同时P中直接存储了而所有他所管理的协程中的上下文环境,函数指针,堆栈地址边界等等, 因而避免了类似传统线程之间切换带来的开销。

当一个M内核线程在执行一个带有阻塞任务的协程G时,P可以将这个内核线程M单独分离出来,同时Go语言逻辑处理器P去寻找一个其他空闲的内核线程来执行其他的协程们。这样的机制大大减少了阻塞带来的性能损耗。

  • 抢占式调度规则:Golang具有抢占是调度的规则,当有协程G长时间运行时可以被调度器P终端,一边其他的协程G获得执行机会,保证系统的响应性。
  • 工作窃取(Work Stealing):当一个逻辑处理器P处理完成起本地队列中所有的协程G们是,他可以从别的P逻辑处理器队列中窃取协程任务,这样有助于负载平衡,提升处理效率。
  • 运行时优化:Golang的运行时包含内存管理和垃圾回收机制,这些机制优化内存的使用同时减少了内存泄漏和内存碎片问题。同时Go的协程开销非常低,每个协程只占用2KB的左右的栈内存,这样使得协程的创建销毁成本相较于用户态线程而言耕地,可以支持大量的并发操作。

总结

Golang采用的协程,是不同于Python中协程的概念。其也不依赖于常规的用户态线程。而是采用了一套自身的GPM协程调度管理逻辑,将多个协程由某个逻辑处理器管理并实际交给一个用户态线程执行。

即Golang所使用的内核线程对应一个Golang的逻辑处理器,对应多个Golang协程,1:1:N,同时可以有多个内核线程被Golang使用。

后记

这篇文章来来回回写了鸽,鸽了写从二月开始写到了7月,终于是挤出来了。但是已经完全无法保证文章内容的流利性了,悲...

updatedupdated2025-04-162025-04-16