看了篇微信后台异步化改造的文章, 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的是个轻量级进程, 共享了地址空间文件等等资源. 看代码:
|
|
各种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 实现的关键
协程怎么实现, 我大致能想象到如下几个点:
- context的切换和管理, 各种寄存器的保存, rip的设置,返回地址等等. 这部分可以用汇编实现.
- 需要一个类似Linux kernel的内核堆栈, 用来保存协程上下文. 这个堆栈的大小和管理需考虑.
- 没有系统的中断(int/sysenter), 协程不好抢占, 用协作方式实现更简单, 协程自己让出运行状态. API如何设计, 既简单又不容易出错, 需要费脑子想想.
先只看看context的切换的原理.
3.2 glibc的实现
glibc有ucontext, 可以用来实现协程, 还有人用setjmp/longjmp来实现协程. 我只看下setjmp/longjmp在glibc的实现:
glibc源码太难读, 直接gdb:
|
|
与想象的差不多, 用setjmp/longjmp得很小心栈寄存器(rbp, rsp)和PC寄存器(rip),不小心就段错误了.
3.2 libco的实现
腾讯libco也类似,保存上下文的寄存器和返回地址, 看了下coctx_swap关于context切换的实现, 汇编代码, 逻辑其实很简单, 我加下注释:
|
|
可以看到其实协程的context很轻量级, 只是把十几个通用寄存器保存起来, 各种浮点寄存器,sse,xmm,avx等等寄存器都不管了.
4. 猴年马月
本来想看看goroutine在go runtime上是怎样实现的, 结果发现go的runtime是用go语言本身实现的… 不太熟悉go, 遂放弃…
嗯,等有空再细看libco的api. 猴年马月时, 自己实现一个? :-)
// end