进程/线程/协程的实现原理

看了篇微信后台异步化改造的文章, libco协程库加上epoll处理高并发. 企鹅厂是C++控, 后台用C++. 微信这种高并发, 见识了协程的用途. C/C++语言不提供协程的语义. 听过有人用setjmp/longjmp或ucontext实现协程, 一直对这种用户态切换没细看, 估计和操作系统切换进程类似, tss保存起来. 好奇心起, 趁机了解一下协程的实现, 同时整理一下进程/线程的原理.

1. Linux内核的进程

码农常说的进程/线程一般是用户态的概念.对Linux内核而言,只有一种类型,就是进程, 源码里对应的描述符就是那个很复杂很重要一大坨的数据结构 task_struct. 对于内核来说,每个线程有个对应内核栈,用来保存线程的上下文和thread_info(指向task结构).
为了减小线程开销,传统的fork做了很多改进,vfork/clone等. linux系统调用clone()创建新进程时,通过flag参数,允许共享一部分资源,例如地址空间(页表),文件、信号等等,这就引出轻量进程的概念.

(另,所谓内核线程是另外一个概念, 在内核态运行, 称之为线程是因为它没有虚拟地址空间. 是Linux内核的一个概念.)

2. 用户态线程在linux上的实现

用户态的进程在内核上用task_struct数据结构描述. 既然Linux内核眼里只有task_struct, 那么问题来了, 用户态所谓多线程, 在linux上是怎样实现的呢? 这个活其实是glibc的NPTL(Native POSIX Thread Library)帮忙干的.
glibc还是调用clone() 系统调用, Linux内核还是用task_struct来描述线程, 只是clone的是个轻量级进程, 共享了地址空间文件等等资源. 看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// git clone http://sourceware.org/git/glibc.git
// sysdeps/unix/sysv/linux/createthread.c
create_thread (struct pthread *pd, const struct pthread_attr *attr,
bool *stopped_start, STACK_VARIABLES_PARMS, bool *thread_ran)
{
//...
const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM
| CLONE_SIGHAND | CLONE_THREAD
| CLONE_SETTLS | CLONE_PARENT_SETTID
| CLONE_CHILD_CLEARTID
| 0);
//...
}

各种cloning flags的含义可以用man查看(源码可看cloning flags):

  • CLONE_VM: the calling process and the child process run in the same memory space
  • CLONE_FS: the caller and the child process share the same file system information
  • CLONE_FILES: share the same file descriptor table
  • CLONE_SETTLS: TLS (Thread Local Storage) descriptor
  • CLONE_THREAD: the child is placed in the same thread group as the calling process
  • CLONE_SYSVSEM: the calling process and the child process share the same table of signal handlers
    可以看到, cloning flags共享了地址空间文件等等资源, 这样省去全局页表等刷新, 线程比进程要轻量级很多.
    Linux内核在 task_struct 中引入了 pid 与 tgid. 一个用户态的线程也是对应一个task_stuct的, 调度还是内核来干. 用户态的多个线程有相同的进程id, 用户态的线程中调用getpid()拿到的其实是task_struct的tgid.

顺便瞄了一眼JVM HotSpot的实现, 也是调用phtread实现的. 所以一个Java的线程, 在Linux内核中也是用一个task_struct来管理的.

2.2 线程池

线程在linux内核中也是一个需要用task_struct来管理的单元. 线程过多的时候, 内核的成本也不小. 我们用thread pool来降低成本.

3. 协程

所谓coroutine, 与内核没关系, 用户态上实现的调度, 没有内核的context switch成本. 可以想到, coroutine没法分配到多个CPU core里去, 因为操作系统根本不知道协程, 何谈分配cpu呢. 不是说协程就比线程好, 也看应用场景, 比如很多并发IO, 用线程去处理可能成本高, 那么可以用协程, 相当于复用一个进程/线程. 我们要的只是一个进程/线程里的多个协程就能低成本地处理大量的任务就行.

3.1 实现的关键

协程怎么实现, 我大致能想象到如下几个点:

  1. context的切换和管理, 各种寄存器的保存, rip的设置,返回地址等等. 这部分可以用汇编实现.
  2. 需要一个类似Linux kernel的内核堆栈, 用来保存协程上下文. 这个堆栈的大小和管理需考虑.
  3. 没有系统的中断(int/sysenter), 协程不好抢占, 用协作方式实现更简单, 协程自己让出运行状态. API如何设计, 既简单又不容易出错, 需要费脑子想想.

先只看看context的切换的原理.

3.2 glibc的实现

glibc有ucontext, 可以用来实现协程, 还有人用setjmp/longjmp来实现协程. 我只看下setjmp/longjmp在glibc的实现:
glibc源码太难读, 直接gdb:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(gdb) disass __sigsetjmp
Dump of assembler code for function __sigsetjmp:
=> 0x00007ffff7a431b0 <+0>: mov %rbx,(%rdi) //(%rdi) zhxiang jmp_buf
0x00007ffff7a431b3 <+3>: mov %rbp,%rax
0x00007ffff7a431b6 <+6>: xor %fs:0x30,%rax
0x00007ffff7a431bf <+15>: rol $0x11,%rax
0x00007ffff7a431c3 <+19>: mov %rax,0x8(%rdi)
0x00007ffff7a431c7 <+23>: mov %r12,0x10(%rdi)
0x00007ffff7a431cb <+27>: mov %r13,0x18(%rdi)
0x00007ffff7a431cf <+31>: mov %r14,0x20(%rdi)
0x00007ffff7a431d3 <+35>: mov %r15,0x28(%rdi)
0x00007ffff7a431d7 <+39>: lea 0x8(%rsp),%rdx ;//
0x00007ffff7a431dc <+44>: xor %fs:0x30,%rdx
0x00007ffff7a431e5 <+53>: rol $0x11,%rdx
0x00007ffff7a431e9 <+57>: mov %rdx,0x30(%rdi)
0x00007ffff7a431ed <+61>: mov (%rsp),%rax ;//保存返回地址,即调用处的PC
0x00007ffff7a431f1 <+65>: nop
0x00007ffff7a431f2 <+66>: xor %fs:0x30,%rax
0x00007ffff7a431fb <+75>: rol $0x11,%rax
0x00007ffff7a431ff <+79>: mov %rax,0x38(%rdi) ; //保存到jmp_buf
0x00007ffff7a43203 <+83>: jmpq 0x7ffff7a43210 <__sigjmp_save>
End of assembler dump.
(gdb) disass __sigjmp_save
Dump of assembler code for function __sigjmp_save:
0x00007ffff7df0db0 <+0>: movl $0x0,0x40(%rdi)
0x00007ffff7df0db7 <+7>: xor %eax,%eax
0x00007ffff7df0db9 <+9>: retq ; retq返回到调用处的PC

与想象的差不多, 用setjmp/longjmp得很小心栈寄存器(rbp, rsp)和PC寄存器(rip),不小心就段错误了.

3.2 libco的实现

腾讯libco也类似,保存上下文的寄存器和返回地址, 看了下coctx_swap关于context切换的实现, 汇编代码, 逻辑其实很简单, 我加下注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
; 调用方式coctx_swap(&(curr->ctx),&(pending_co->)ctx)
leaq 8(%rsp),%rax
leaq 112(%rdi),%rsp ; rsi和rdi指向两个协程context保存的内存空间
; 112 = 14 * 8,
; 栈顶rsp指向这,用来保存13个寄存器和一个ret func addr
pushq %rax
pushq %rbx
pushq %rcx
pushq %rdx
pushq -8(%rax) //ret func addr
pushq %rsi
pushq %rdi
pushq %rbp
pushq %r8
pushq %r9
pushq %r12
pushq %r13
pushq %r14
pushq %r15
movq %rsi, %rsp
popq %r15
popq %r14
popq %r13
popq %r12
popq %r9
popq %r8
popq %rbp
popq %rdi
popq %rsi
popq %rax ; ret func addr
popq %rdx
popq %rcx
popq %rbx
popq %rsp
pushq %rax ; 用来给后面ret指令返回
xorl %eax, %eax
ret ; ret指令用栈中的数据修改IP的内容,跳转

可以看到其实协程的context很轻量级, 只是把十几个通用寄存器保存起来, 各种浮点寄存器,sse,xmm,avx等等寄存器都不管了.

4. 猴年马月

本来想看看goroutine在go runtime上是怎样实现的, 结果发现go的runtime是用go语言本身实现的… 不太熟悉go, 遂放弃…
嗯,等有空再细看libco的api. 猴年马月时, 自己实现一个? :-)
// end