进程线程协程的本质区别

现代多任务操作系统通常都会有进程的概念来对任务进行隔离,而为了充分利用多核处理器性能同时又减少进程创建的开销,通常又都会引入更细粒度的调度单元:线程

我们经常能在教科书上看到对于进程和线程的定义:

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

这句话没错,但是只是从职责上给出的定义,而不是基于底层实现出发的。

广义上的线程分为内核态线程用户态线程两种,内核态线程受操作系统直接调度可以充分利用硬件资源,而用户态线程实现简单上下文切换开销小,后者也被称作我们所熟知的:协程

那么进程线程和协程它们在实现上到底有何区别呢?

所有计算机领域问题的最好答案都在源代码中,以 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 下,因为底层数据结构是一样的,所以进程和线程几乎一模一样,唯一的区别就是,线程之间会共享内存、文件等资源,而进程之间是完全隔离的。

协程

很多编程语言都有对于协程的支持,比如:erlangpythongo

协程又被称为用户态线程,在 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 中窃取一半的协程到当前队列上,再从当前队列中获取。

image-20210330195243071

为什么说协程比线程更轻量

  • 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 中进程就是线程这种结构体的抽象,而线程和协程则在数据结构、调度策略、创建方式、状态枚举、个体间通信方式等非常多的方面存在巨大的差异,具体可以再参考上面的对比表,这里就不再展开了。

image-20210330194604528