现代多任务操作系统通常都会有进程
的概念来对任务进行隔离,而为了充分利用多核处理器性能同时又减少进程
创建的开销,通常又都会引入更细粒度的调度单元:线程
。
我们经常能在教科书上看到对于进程和线程的定义:
进程是操作系统分配资源的最小单位,线程是进行调度的最小单元。
这句话没错,但是只是从职责上给出的定义,而不是基于底层实现出发的。
广义上的线程分为内核态线程
和用户态线程
两种,内核态线程受操作系统直接调度可以充分利用硬件资源,而用户态线程实现简单上下文切换开销小,后者也被称作我们所熟知的:协程
。
那么进程线程和协程它们在实现上到底有何区别呢?
所有计算机领域问题的最好答案都在源代码中,以 Linux 操作系统中的进程和线程实现及 Go 语言实现的协程为例,我们一起探讨一下这三者到底有何本质区别吧。
进程
进程本质上是对 CPU 要执行的代码段的一个抽象,用于更好的隔离数据、管理资源、彼此区分等等。
Unix类系统中,只有一种方法能启动一个新的进程:fork。
fork 会将当前进程的所有资源全部复制一份,创建一个子进程,子进程和父进程接着会执行同一份代码,代码中可以通过判断 fork 函数返回的结果来让代码走到不同的分支。所有的进程都是 init 进程的子进程,对于复制出来的新进程,Linux 采样了 copy-on-write 的策略,只有在用到时才会真正去复制,避免空间浪费。
也可以通过 exce 运行一段新的程序,但是 exec 只是将当前运行中的程序替换为指定的程序,pid 还是保持不变的。
进程数据结构
Linux 中进程的代码实现是一个 task_struct 对象,定义于:/include/linux/sched.h
,主要包括:各种ID、进程状态、进程调度、内存、文件、ptrace、信号处理等功能相关的字段,整理了重要的字段,定义如下:
struct task_struct {
/*** 各种标识符 ***/
pid_t pid; // process id 进程ID or 线程ID
pid_t tgid; // 线程对应的进程ID
uid_t uid,euid,suid,fsuid; // 进程所属用户ID
gid_t gid,egid,sgid,fsgid; // 进程所属组ID
/*** 状态信息 ***/
volatile long state; // 进程状态,-1 不可运行,0 可以运行,> 0 停止状态(多种状态码细分)
struct thread_info *thread_info; // 内核栈空间 8KB
unsigned long flags; // 一些进程状态的位图集合
int exit_code; // 终止返回的结束码
struct timespec start_time; // 进程创建时间
/*** 调度信息 ***/
unsigned long policy; // 调度策略
int prio; // 动态优先级
int static_prio; // 静态优先级:nice 值
struct sched_info sched_info; // 用于调度器统计进程的运行信息:运行时间、等待时间等
unsigned long rt_priority; // 实时优先级
unsigned long nvcsw, nivcsw; // 上下文切换次数:自愿/非自愿
cputime_t utime, stime; // utime 用户态执行时间、stime 内核态执行时间
/*** 内存信息 ***/
struct mm_struct *mm, *active_mm; // 进程地址空间;内核线程所借用的地址空间
/*** 指向其他进程的变量:用于构建进程数据集合 ***/
struct task_struct *parent; // 父进程
struct list_head children; // 子进程
struct list_head sibling; // 兄弟进程
struct list_head tasks; // 用于构建进程链表,包含一个 pre 和 next
struct pid pids[PIDTYPE_MAX]; // 用于构建进程HashMap
/*** 文件信息 ***/
struct fs_struct *fs; // 所在文件系统
struct files_struct *files; // 打开文件
u64 rchar, wchar, syscr, syscw; // I/O统计信息:读、写、系统调用读、系统调用写
void *journal_info; // journal 日志文件系统信息
struct backing_dev_info *backing_dev_info; // 块设备IO信息
struct io_context *io_context; // I/O调度器使用
/*** 子进程追踪控制(ptrace)相关信息 ***/
unsigned long ptrace;
struct list_head ptrace_children;
struct list_head ptrace_list;
unsigned long ptrace_message;
/*** 其他 ***/
struct namespace *namespace; // 所在命名空间
struct signal_struct *signal; // 指向进程的信号描述符
struct sighand_struct *sighand; // 信号处理函数
};
进程状态
一个进程在 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) | |
32 | EXIT_DEAD | X (dead) |
调度策略
CPU在运行代码时,当前的这个进程不可能一直的跑下去,它会阻塞、会退出,所以这个时候需要再找一个其他的进程给CPU来执行,怎么缜密合理地完成这个过程呢?如何让整体程序运行吞吐量高同时又不会让某些进程饿死?这就是 Linux 进程调度模块所完成的事情。
Linux 在如下场景时会触发进程调度:
- 进程由运行变为终止、睡眠等状态时
- 当前进程时间片用完时
- 进程从中断、异常及系统调用返回到用户态时
- 显示触发进程调度
主要的调度逻辑实现于 /kernal/sched.c
文件中的 schedule
方法中。
Linux 系统使用了一种叫做 CFS (Compeletly Fair Scheduler)
的可抢占支持优先级时间片动态轮转调度算法。
首先会根据CPU的负载状况确定一个调度的时间区间,然后将这个区间根据各个可运行进程的静态优先级(nice值)划分给每个进程一个时间片,这个时间片即动态优先级,然后将所有的进程按照时间片的大小构建成一棵红黑树,每次调度时取红黑树中边上时间片最多的进程运行,直到红黑树所有节点都用完了时间片就会触发下一轮的调度。
基本思路就是让最少运行的进程运行,所以叫做:基本公平调度算法。
线程
在有的系统里面,线程是做为一个从属于进程存在的实体而存在的,比如 Windows,但在 Linux 中,线程和进程底层数据结构是一样的,都是 /include/linux/sched.h
头文件中的 task_struct
结构体,所以 Linux 下的线程通常又被称为轻量级进程。
Linux 下可以通过 clone
创建一个线程,clone
和 fork 一样都会复制一个执行同一份代码的新进程,但区别是,clone
函数有一个参数选项,可以选择要拷贝哪些数据,而不是像 fork 那样全部拷贝。
Linux 创建线程的底层函数和创建进程一样,也是 do_fork
:
do_fork(unsigned long, unsigned long, struct pt_regs *, unsigned long, int __user *, int __user *);
只是创建线程时,第一个参数会多传入如下四个拷贝标志位:
- CLONE_VM:共享虚拟内存
- CLONE_FS:共享文件系统
- CLONE_FILES:共享打开的文件
- CLONE_SIGHAND:共享信号处理(相同的 signal handler 表)
所以线程之间会指向同一份内存和打开文件等资源,这使得线程创建的远小于进程,同时共享内存使得线程之间相互协作通信的效率也非常的高。
另外线程作为一个调度单元,使得进程获得了在 SMP 多处理器架构并发运行的能力,能大大提高应用程序的效率。
线程和进程的区别
在 Linux 下,因为底层数据结构是一样的,所以进程和线程几乎一模一样,唯一的区别就是,线程之间会共享内存、文件等资源,而进程之间是完全隔离的。
协程
很多编程语言都有对于协程的支持,比如:erlang
、python
、go
。
协程又被称为用户态线程,在 Go 语言中,采用 M-P-G
的并发模型来充分利用协程的效率。
Machine
代表一个底层的操作系统线程,Processor
协程的管理者,是 Go 语言抽象的一个处理器,运行时会绑定一个可运行的 M,当 M 不可运行(比如陷入系统调用)时 P 则会带着 G 去投奔另外的 M。 P 中管理了一个协程队列,M 每次就从这个队列中取协程来运行,中途阻塞或者占用时间片过久都会触发协程调度,使得 P 一直能处于全力运转状态,所以 P 的数量通常是等于 CPU 核心数量的,这样没有上下文切换带来的性能损耗,不过我们也可以 通过GOMAXPROCS
环境变量来设置 P 的数量。Goroutine
协程,详细的结构我们下面再介绍。
协程数据结构
协程的底层数据结构是 runtime 包中的 g 结构体,包含了一段代码运行所需要的方法栈、程序计数器、栈指针等所有结构,这里选取了一些核心的字段标注如下:
type g struct {
stack struct { // 方法栈,初始大小 2K,每次扩容一倍
lo uintptr // 栈开始位置
hi uintptr // 栈结束位置
}
_panic *_panic // panic 调用链
_defer *_defer // defer 调用链
m *m // 指向当前的 M
sched struct { // 运行上下文,用于调度恢复
sp uintptr // stack pointers 栈指针
pc uintptr // program counter 程序计数器
ctxt unsafe.Pointer // 闭包函数的上下文
bp uintptr // 栈底指针
}
atomicstatus uint32 // 协程状态
goid int64 // 协程ID,不对外暴露
waitsince int64 // approx time when the g become blocked
waitreason waitReason // if status==Gwaiting
preempt bool // 是否运行抢占当前协程
lockedm muintptr // 用于锁定M运行
startpc uintptr // 创建协程时传入的函数的
timer *timer // 调用time.Sleep的计时器
}
所有的协程都维护在一个全局的数据结构:allgs 中:
allgs []*g
allgs
是一个切片,因此协程的数量会受到切片的长度限制,而切片的长度用了一个 int
表示,所以在64位机器下协程的最大数量为:2^63 - 1
。
协程状态
协程具有如下状态,枚举于:/runtime/runtime2.go
文件中:
值 | 状态 | 说明 |
---|---|---|
0 | _Gidle | 刚刚创建,还没有初始化 |
1 | _Grunnable | 可运行,排队中 |
2 | _Grunning | 可能在运行中,已经绑定了 M P |
3 | _Gsyscall | 绑定了 M,在执行系统调用中 |
4 | _Gwaiting | 阻塞了(锁、管道),等待中 |
5 | _Gmoribund_unused | 暂时没用 |
6 | _Gdead | 没有在使用,可能在等待复用 |
7 | _Genqueue_unused | 暂时没用 |
8 | _Gcopystack | 栈复制中 |
9 | _Gpreempted | 被抢占了 |
10 | _Gscan | GC正在扫描这个协程 |
调度逻辑
Go的协程采用了一种无优先级可抢占FIFO时间片轮转调度算法:Go 的协程调度本质是给绑定在 P 的 M 选择下一个可以运行的 G 来运行的过程。
P 中存在一个协程的等待队列,这些协程会按队列顺序被 M 执行。同时 Go 会启动一个特殊的不绑定 P 的线程:sysmon (System Monitor),sysmon 的一部分职责就是遍历所有的 P,将在 P 上运行超过 10ms
的协程标记为可抢占。然后在 G 在函数调用时就会触发抢占调度,重新回到运行队列中排队。
P 和 M 并不是一一对应的,比如当底层线程 M 陷入系统调用时,P 就会带着它所有的 G 另绑空闲的 M 去运行。
在 P 上的队列没有可以运行的协程时,会尝试去全局的协程队列中寻找,如果还是没有,那么会随机的尝试从其他 P 中窃取一半的协程到当前队列上,再从当前队列中获取。
为什么说协程比线程更轻量
- 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-1 2. 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) |
同进程 | idle runnable running syscall waiting dead copystack preempted scan |
创建开销 | 8MB初始栈(可以通过unlimit设置) | 同进程 | 2KB初始栈 |
切换开销 | 高:需要切换寄存器、栈指针、程序计数器等上下文,需要进行系统调用 | 同进程 | 低:需要切换栈指针、程序计数器等上下文,不需要进行系统调用 |
个体间通信方式 | - 通过操作系统提供的信息交换API:信号、管道、命名管道、消息队列 - 直接共享内存 - socket |
线程之间内存天然共享,可以辅以锁等手段来协助通信 | 同线程,不过 Go 提供了很方便的机制:channel 来通信 |
标识 | PID(Process ID) TGID(Thread Group ID) |
PID同进程,有时候也叫TID(Thread ID) | GID(Goroutine ID,不对外暴露) |
调度周期 | 根据CPU运行状态计算 | 同进程 | 10ms |
总结
最后再简单简单总结一下进程、线程和协程的本质区别:
以最常见的Linux进程、线程和Go协程为例,进程和线程在代码实现上是共用的同一个结构体(
task_struct
),更准确地来说,task_struct
结构体代表一个线程,而进程是一组具有相同进程号task_struct
的抽象概念。同一进程内线程中的打开文件和内存等指针指向的是同一个地址,所以线程的创建开销比进程小很多。而线程同时又是操作系统CPU运行调度的基本单元,其调度和栈指针等信息是私有的,能独立运行代码充分利用CPU资源。
协程则是在线程运行的代码上自己再实现了一套代码段隔离、调度逻辑,主要是考虑到线程创建、切换要进行系统调用、资源占用多等问题,性能消耗相对较大,自己实现一套逻辑则可以更加灵活的管理和调度,同时提供更加友好易用的上层接口,降低并发编程门槛,充分利用CPU资源。
再具体一点而言,线程和进程本质上区别不大,因为在
Linux
中进程就是线程这种结构体的抽象,而线程和协程则在数据结构、调度策略、创建方式、状态枚举、个体间通信方式等非常多的方面存在巨大的差异,具体可以再参考上面的对比表,这里就不再展开了。