现代多任务操作系统通常都会有进程的概念来对任务进行隔离,而为了充分利用多核处理器性能同时又减少进程创建的开销,通常又都会引入更细粒度的调度单元:线程。
那么进程线程和协程它们在实现上到底有何区别呢?
所有计算机领域问题的最好答案都在源代码中,以 Linux 操作系统中的进程和线程实现及 Go 语言实现的协程为例,我们一起探讨一下这三者到底有何本质区别吧。
进程
程序本质上就是文本,然后计算机运行的指令,所以需要将程序编译为代码加载到内存中,然而,程序多了之后,就出现了问题:哪些代码先执行?执行到哪个位置了?需要哪些资源?目前占用了什么资源?。所以,为了方便操作系统管理这些代码就会创建进程,就是一个数据结构,记录着“正在运行状态”的代码的相关信息。注意这个“正在运行状态”,不一定是“正在CPU上运行”,只要程序加载到内存了,就是“正在运行状态”。
相当于,只要你去工地搬砖,工地管理人员就会拿个小本子新开一页,给你个工号,把你的信息记录下来,比如分配你几双手套,几个安全帽,让你搬几批砖,搬什么砖等等。你现在虽然没有干活,但是在工地里,朋友给你发微信,你也只能回:“正在干活”。
进程本质上就是在处理器上交替执行的所有任务的共性提取,是处理器层面的并发抽象。
当进程运行的时候需要给它分配资源,这些资源包括内存中的堆区、栈区、文件映射区、静态常量区、代码段和常量区。
所以,进程是操作系统分配资源的最小单元。
进程的数据结构
Linux 中进程的代码实现是一个 task_struct 对象,定义于:/include/linux/sched.h,主要包括:各种ID、进程状态、进程调度、内存、文件、ptrace、信号处理等功能相关的字段,整理了重要的字段,定义如下:
1 | struct task_struct { |
进程状态
一个进程在 Linux 源码中存在如下六个枚举状态:
值 | 状态 | 代号 | 说明 |
---|---|---|---|
0 | TASK_RUNNING | R (running) | 运行状态 |
1 | TASK_INTERRUPTIBLE | S (sleeping) | 可中断的睡眠状态。正在等待某个条件满足 |
2 | TASK_UNINTERRUPTIBLE | D (disk sleep) | 不可中断的睡眠状态。不会被信号中断 |
4 | TASK_STOPPED | T (stopped) | 暂停状态。收到某种信号,运行被停止 |
8 | TASK_TRACED | T (tracing stop) | 被跟踪状态。进程停止,被另一个进程跟踪 |
16 | EXIT_ZOMBIE | Z (zombie) | 僵尸状态。进程已经退出,但尚未被父进程或者init进程收尸 |
32 | EXIT_DEAD | X (dead) | 真正的死亡状态 |
僵尸进程、孤儿进程、守护进程
这里区分下僵尸进程、孤儿进程、守护进程的概念。
- 僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。
- 孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
由于孤儿进程会被init进程给收养,所以孤儿进程不会对系统造成危害 - 守护进程:Linux Daemon(守护进程)是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。它不需要用户输入就能运行而且提供某种服务,不是对整个系统就是对某个用户程序提供服务。Linux系统的大多数服务器就是通过守护进程实现的。常见的守护进程包括系统日志进程syslogd、 web服务器httpd、邮件服务器sendmail和数据库服务器mysqld等。
线程
在 Linux 中,线程和进程底层数据结构是一样的,都是 /include/linux/sched.h头文件中的 task_struct 结构体,所以 Linux 下的线程通常又被称为轻量级进程。
在 Linux 下,因为底层数据结构是一样的,所以进程和线程几乎一模一样,唯一的区别就是,线程之间会共享内存、文件等资源,而进程之间是完全隔离的。
线程是进程层面的并发抽象,就是进程中正在执行的代码,所以操作系统要觉得哪些代码在CPU上跑,哪些等着,即线程是操作系统调度的最小单元。
协程
线程又能够分为“用户态线程”和“内核态线程”。
内核态线程受操作系统直接调度可以充分利用硬件资源,而用户态线程实现简单上下文切换开销小,后者也被称作我们所熟知的:协程。
在 Go 语言中,采用 M-P-G 的并发模型来充分利用协程的效率。
- Machine代表一个底层的操作系统线程
- Processor 协程的管理者,是 Go 语言抽象的一个逻辑处理器,运行时会绑定一个可运行的 M,当 M 不可运行(比如陷入系统调用)时 P 则会带着 G 去投奔另外的 M。 P 中管理了一个协程队列,M 每次就从这个队列中取协程来运行,中途阻塞或者占用时间片过久都会触发协程调度,使得 P 一直能处于全力运转状态,所以 P 的数量通常是等于 CPU 核心数量的,这样没有上下文切换带来的性能损耗,不过我们也可以 通过 GOMAXPROCS环境变量来设置 P 的数量。
- Goroutine 协程,详细的结构我们下面再介绍。
协程数据结构
协程的底层数据结构是 runtime 包中的 g 结构体,包含了一段代码运行所需要的方法栈、程序计数器、栈指针等所有结构,这里选取了一些核心的字段标注如下:
1 | type g struct { |
所有的协程都维护在一个全局的数据结构:allgs 中:
1 | allgs []*g |
allgs 是一个切片,因此协程的数量会受到切片的长度限制,而切片的长度用了一个 int 表示,所以在64位机器下协程的最大数量为:2^63 - 1。
为什么说协程比线程更轻量
- Go协程默认的栈空间内存大小只有 2KB(上限1GB),而Linux线程栈大小默认是8MB,4096倍的差距。单从数据结构上而言,协程结构体只有大概50个成员,而线程结构体拥有100个左右的成员,比协程多一倍。
- 线程切换需要进行系统调用,开销比普通函数调用大很大,而协程则完全在用户态实现没有这个开销
进程线程协程对比表
进程 | 线程 | 协程 | |
---|---|---|---|
数据结构 | linux:/include/linux/sched.h: *task_struct | linux:/include/linux/sched.h: *task_struct | go:/runtime/runtime2.go:*g |
调度策略 | CFS(Compeletly Fair Scheduler)可抢占支持优先级时间片动态轮转调度算法 | 同进程 | 无优先级可抢占FIFO时间片轮转调度算法 |
调度是否公平 | 相对公平 | 同进程 | 不是那么公平 |
维护集合 | - 进程链表- 进程HashMap | 同进程 | 全局协程列表:allgs |
数量限制 | 1. 最大上限:2^32-12. unlimit -a 查看限制 | 同进程 | 2 ^ 63 -1 |
如何创建 | 调用 fork() | 调用 fork() | 调用 go 关键字 |
最大优势 | 进程之间资源彻底隔离 | 实现简单,直接调用系统 API 即可 | 上下文切换快速 |
调度优先级 | 支持(nice值) | 同进程 | 不支持 |
终止方式 | 调用 exit() | pthread_cancel、pthread_exit、自然退出 | 自然退出 |
状态枚举 | R (running)S (sleeping)D (disk sleep)T (stopped)T (tracing stop)Z (zombie)X (dead) | 同进程 | idlerunnablerunningsyscallwaitingdeadcopystackpreemptedscan |
创建开销 | 8MB初始栈(可以通过unlimit设置) | 同进程 | 2KB初始栈 |
切换开销 | 高:需要切换寄存器、栈指针、程序计数器等上下文,需要进行系统调用 | 同进程 | 低:需要切换栈指针、程序计数器等上下文,不需要进行系统调用 |
个体间通信方式 | - 通过操作系统提供的信息交换API:信号、管道、命名管道、消息队列- 直接共享内存- socket | 线程之间内存天然共享,可以辅以锁等手段来协助通信 | 同线程,不过 Go 提供了很方便的机制:channel 来通信 |
标识 | PID(Process ID)TGID(Thread Group ID) | PID同进程,有时候也叫TID(Thread ID) | GID(Goroutine ID,不对外暴露) |
调度周期 | 根据CPU运行状态计算 | 同进程 | 10ms |