01.02 linux进程-线程-协程上下文环境的切换与实现

一:进程-线程-协程简介

进程和线程的主要区别是:进程独享地址空间和资源,线程则共享地址空间和资源,多线程就是多栈。

以下进程 线程 协程等等视频资料需要的朋友可以后台私信【架构】免费获取


linux进程-线程-协程上下文环境的切换与实现

linux进程-线程-协程上下文环境的切换与实现

1、进程

进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。

  2、线程

线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

  3、协程

协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

调度

进程调度,切换进程上下文,包括分配的内存,包括数据段,附加段,堆栈段,代码段,以及一些表格。
线程调度,切换线程上下文,主要切换堆栈,以及各寄存器,因为同一个进程里的线程除了堆栈不同。

协程又称为轻量级线程,每个协程都自带了一个栈,可以认为一个协程就是一个函数和这个存放这个函数运行时数据的栈,这个栈非常小,一般只有几十kb。

linux进程-线程-协程上下文环境的切换与实现

什么是协程

wikipedia 的定义:协程是一个无优先级的子程序调度组件,允许子程序在特点的地方挂起恢复。

线程包含于进程,协程包含于线程。只要内存足够,一个线程中可以有任意多个协程,但某一时刻只能有一个协程在运行,多个协程分享该线程分配到的计算机资源。

为什么需要协程

简单引入

就实际使用理解来讲,协程允许我们写同步代码的逻辑,却做着异步的事,避免了回调嵌套,使得代码逻辑清晰。code like this:

<code>   co(function*(next){     let [err,data]=yield fs.readFile("./test.txt",next);//异步读文件     [err]=yield fs.appendFile("./test2.txt",data,next);//异步写文件     //....   })()/<code>

异步 指令执行之后,结果并不立即显现的操作称为异步操作。及其指令执行完成并不代表操作完成。

协程是追求极限性能和优美的代码结构的产物。

一点历史

起初人们喜欢同步编程,然后发现有一堆线程因为I/O卡在那里,并发上不去,资源严重浪费。

然后出了异步(select,epoll,kqueue,etc),将I/O操作交给内核线程,自己注册一个回调函数处理最终结果。

然而项目大了之后代码结构变得不清晰,下面是个小例子。

<code>  async_func1("hello world",func(){     async_func2("what's up?",func(){       async_func2("oh ,friend!",func(){          //todo something       })     })  })/<code>

于是发明了协程,写同步的代码,享受着异步带来的性能优势。

程序运行是需要的资源:

  • cpu
  • 内存
  • I/O (文件、网络,磁盘(内存访问不在一个层级,忽略不计))

协程的实现原理(异步实现)

libco 一个C++协程库实现

libco 是腾讯开源的一个C++协程库,作为微信后台的基础库,经受住了实际的检验。项目地址:https://github.com/Tencent/libco

个人源码阅读项目:https://github.com/yyrdl/libco-code-study (未完结)

libco源代码文件一共11个,其中一个是汇编代码,其余是C++,阅读起来相对较容易。

在C++里面实现协程要解决的问题有如下几个:

  • 何时挂起协程?何时唤醒协程?
  • 如何挂起、唤醒协程,如何保护协程运行时的上下文?
  • 如何封装异步操作?

前期知识准备

  1. 现代操作系统是分时操作系统,资源分配的基本单位是进程,CPU调度的基本单位是线程。
  2. C++程序运行时会有一个运行时栈,一次函数调用就会在栈上生成一个record
  3. 运行时内存空间分为全局变量区(存放函数,全局变量),栈区,堆区。栈区内存分配从高地址往低地址分配,堆区从低地址往高地址分配。
  4. 下一条指令地址存在于指令寄存器IP,ESP寄存值指向当前栈顶地址,EBP指向当前活动栈帧的基地址。
  5. 发生函数调用时操作为:将参数从右往左依次压栈,将返回地址压栈,将当前EBP寄存器的值压栈,在栈区分配当前函数局部变量所需的空间,表现为修改ESP寄存器的值。
  6. 协程的上下文包含属于他的栈区和寄存器里面存放的值。

何时挂起,唤醒协程?

如开始介绍时所说,协程是为了使用异步的优势,异步操作是为了避免IO操作阻塞线程。那么协程挂起的时刻应该是当前协程发起异步操作的时候,而唤醒应该在其他协程退出,并且他的异步操作完成时。

如何挂起、唤醒协程,如何保护协程运行时的上下文?

协程发起异步操作的时刻是该挂起协程的时刻,为了保证唤醒时能正常运行,需要正确保存并恢复其运行时的上下文。

所以这里的操作步骤为:

  • 保存当前协程的上下文(运行栈,返回地址,寄存器状态)
  • 设置将要唤醒的协程的入口指令地址到IP寄存器
  • 恢复将要唤醒的协程的上下文

二:什么是上下文切换

即使是单核CPU也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程时同时执行的,时间片一般是几十毫秒(ms)。

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再次加载这个任务的状态,从任务保存到再加载的过程就是一次上下文切换。

三:线程上下文切换和进程上下文切换的区别

进程切换分两步
1.切换页目录以使用新的地址空间
2.切换内核栈和硬件上下文。
对于linux来说,线程和进程的最大区别就在于地址空间。
对于线程切换,第1步是不需要做的,第2是进程和线程切换都要做的。所以明显是进程切换代价大

线程上下文切换和进程上下问切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。

另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲(processor’s Translation Lookaside Buffer (TLB))或者相当的神马东西会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题。

四:上线文切换的实质

对于线程或者协程来说切换的本质都是堆栈地址的保存和恢复。

大概的切换流程如下:

<code>X86 32 BistsSS  --> 选择子--->段描述表-->(段限,段基址)CR3 --->页目录,页表 ESP--> EBP-->   /<code>

这其实和参数传递有点相似,只需要传递地址就够了。
ESP,EBP 正式 堆栈指针寄存器。


变量通过ESP,EBP 两个指针 加偏移量访问。

五:比较

1、进程多与线程比较

线程是指进程内的一个执行单元,也是进程内的可调度实体。线程与进程的区别:

1) 地址空间:线程是进程内的一个执行单元,进程内至少有一个线程,它们共享进程的地址空间,而进程有自己独立的地址空间

2) 资源拥有:进程是资源分配和拥有的单位,同一个进程内的线程共享进程的资源

3) 线程是处理器调度的基本单位,但进程不是

4) 二者均可并发执行

5) 每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口,但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制

  2、协程多与线程进行比较

1) 一个线程可以多个协程,一个进程也可以单独拥有多个协程,这样python中则能使用多核CPU。

2) 线程进程都是同步机制,而协程则是异步

3) 协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态

4) 协程是用户级的任务调度,线程是内核级的任务调度。

5) 线程是被动调度的,协程是主动调度的。

补充协程上下文环境的切换

协程

协程是一种编程组件,可以在不陷入内核的情况进行上下文切换。如此一来,我们就可以把协程上下文对象关联到fd,让fd就绪后协程恢复执行。

当然,由于当前地址空间和资源描述符的切换无论如何需要内核完成,因此协程所能调度的,只有在同一进程(线程)中的不同上下文而已。

我们在内核里实行上下文切换的时候,其实是将当前所有寄存器保存到内存中,然后从另一块内存中载入另一组已经被保存的寄存器。对于图灵机来说,当前状态寄存器意味着机器状态——也就是整个上下文。其余内容,包括栈上内存,堆上对象,都是直接或者间接的通过寄存器来访问的。 但是请仔细想想,寄存器更换这种事情,似乎不需要进入内核态么。事实上我们在用户态切换的时候,就是用了类似方案。也就是说协程是在用户态保存寄存器状态的!

作为推论:在单个线程中执行的协程,可以视为单线程应用。这些协程,在未执行到特定位置(基本就是阻塞操作)前,是不会被抢占,也不会和其他CPU上的上下文发生同步问题的。因此,一段协程代码,中间没有可能导致阻塞的调用,执行在单个线程中。那么这段内容可以被视为同步的。

我们经常可以看到某些协程应用,一启动就是数个进程。这并不是跨进程调度协程。一般来说,这是将一大群fd分给多个进程,每个进程自己再做fd-协程对应调度。

基于就绪通知的协程框架(epool本身是同步的)

  1. 协程
  2. 首先需要包装read/write,在发生read的时候检查返回。如果是EAGAIN,那么将当前协程标记为阻塞在对应fd上,然后执行调度函数。
  3. 调度函数需要执行epoll(或者从上次的返回结果缓存中取数据,减少内核陷入次数),从中读取一个就绪的fd。如果没有,上下文应当被阻塞到至少有一个fd就绪。
  4. 查找这个fd对应的协程上下文对象,并调度过去。
  5. 当某个协程被调度到时,他多半应当在调度器返回的路上——也就是read/write读不到数据的时候。因此应当再重试读取,失败的话返回1。
  6. 如果读取到数据了,直接返回。

这样,异步的数据读写动作,在我们的想像中就可以变为同步的。而我们知道同步模型会极大降低我们的编程负担。

我们经常可以看到某些协程应用,一启动就是数个进程。这并不是跨进程调度协程。一般来说,这是将一大群fd分给多个进程,每个进程自己再做fd-协程对应调度。

基于就绪通知的协程框架

  1. 首先需要包装read/write,在发生read的时候检查返回。如果是EAGAIN,那么将当前协程标记为阻塞在对应fd上,然后执行调度函数。
  2. 调度函数需要执行epoll(或者从上次的返回结果缓存中取数据,减少内核陷入次数),从中读取一个就绪的fd。如果没有,上下文应当被阻塞到至少有一个fd就绪。
  3. 查找这个fd对应的协程上下文对象,并调度过去。
  4. 当某个协程被调度到时,他多半应当在调度器返回的路上——也就是read/write读不到数据的时候。因此应当再重试读取,失败的话返回1。
  5. 如果读取到数据了,直接返回。

这样,异步的数据读写动作,在我们的想像中就可以变为同步的。而我们知道同步模型会极大降低我们的编程负担。

C/C++怎么实现协程

作为一个C++后台开发,我知道像go, lua之类的语言在语言层面上提供了协程的api,但是我比较关心C++下要怎么实现这一点,下面的讨论都是从C/C++程序员的角度来看协程的问题的。

boost和腾讯都推出了相关的库,语言层面没有提供这个东西。我近期阅读了微信开源的libco协程库,协程核心要解决几个问题:

1. 协程怎么切换? 这个是最核心的问题,有很多trick可以做到这点,libco的做法是利用glibc中ucontext相关调用保存线程上下文,然后用swapcontext来切换协程上下文,libco的实现中对swapcontext的汇编实现做了一些删减和改动,所以在性能上会比C库的swapcontext提升1个数量级。

2. IO阻塞了怎么办?试想在一个多协程的线程里,一个阻塞IO由一个协程发起,那么整个线程都阻塞了,别的协程也拿不到CPU资源,多个协程在一起等着IO的完成。libco中的做法是利用同名函数+dlsym来hook socket族的阻塞IO,比如read/write等,劫持了系统调用之后把这些IO注册到一个epoll的事件循环中,注册完之后把协程yield掉让出cpu资源,在IO完成的时候resume这个协程,这样其实把网络IO的阻塞点放在了epoll上,如果epoll没有就绪fd,那其实在超时时间内epoll还是阻塞的,只是把阻塞的粒度缩小了,本质上其实还是用epoll异步回调来解决网络IO问题的。那么问题来了,对于一些没有fd的一些重IO(比如大规模数据库操作)要怎么处理呢?答案是:libco并没有解决这个问题,而且也很难解决这个问题,首先要明确的一点是我们的目的是让用户只是仅仅调用了一个同步IO而已,不希望用户感知到调用IO的时候其实协程让出了cpu资源,按libco的思路一种可能的方法是,给所有重IO的api都hook掉,然后往某个异步事件库里丢这个IO事件,在异步事件返回的时候再resume协程。这里的难点是可能存在的重IO这么多,很难写成一个通用的库,只能根据业务需求来hook掉需要的调用,然后协程的编写中依然可以以同步的方式调用这些IO。从以上可能的做法来看协程很难去把所有阻塞调用都hook掉,所以libco很聪明的只把socket族的相关调用给hook掉,这样可以libco就成为一个通用的网络层面的协程库,可以很容易移植到现有的代码中进行改造,但是也让libco适用场景局限于如rpc风格的proxy/logic的场景中。在我的理解里,阻塞IO让出cpu是协程要解决的问题,但是不是协程本身的性质,从实现上我们可以看出我们还是在用异步回调的方式在解决这个问题,和协程本身无关。

3. 如果一个协程没有发起IO,但是一直占用CPU资源不让出资源怎么办?无解,所以协程的编写对使用场景很重要,程序员对协程的理解也很重要,协程不适合于处理重cpu密集计算(耗时),只要某个协程即一直占用着线程的资源就是不合理的,因为这样做不到一个合理的并发,多线程同步模型由OS来调度并发,不存在说一个并发点需要让出资源给另一个,而协程在编写的时候cpu资源的让出是由程序员来完成的,所以协程代码的编写需要程序员对协程有比较深刻的理解。最极端的例子是程序员在协程里写个死循环,好,这个线程的所有协程都可以歇歇了。

协程有什么好处

说了这么多协程,协程的好处到底是啥?为什么要使用协程?

1. 协程极大的优化了程序员的编程体验,同步编程风格能快速构建模块,并易于复用,而且有异步的性能(这个看具体库的实现),也不用陷入callback hell的深坑。

2. 第二点也是我最近一直在纠结的一点,协程到底有没有性能提升?

1)从多线程同步模型切到协程来看,首先很明确的性能提升点在于同步到异步的切换,libco中把阻塞的点全部放到了epoll线程中,而协程线程并不会发生阻塞。其次是协程的成本比线程小,线程受栈空间限制,而协程的栈空间由用户控制,而且实现协程需要的辅助数据结构很少,占用的内存少,那么就会有更大的容量,比如可以轻松开10w个协程,但是很难说开10w个线程。另外一个问题是很多人拿线程上下文切换比协程上下文切换开销大来推出协程模型比多线程并发模型性能优这点,这个问题我纠结了很久。对于这个问题,我先做一个简单的具体抽象:在不考虑阻塞的情况下,假设8核的cpu,不考虑抢占中断优先级等因素,100个任务并发执行,100个线程并发和10个线程每个线程10个协程并发对比两者都可以把cpu资源利用起来,对OS来说,前者100个线程参与cpu调度,后者10个线程参与cpu调度,后者还有额外的协程切换调度,先考虑线程切换的上下文,根据Linux内核调度器CFS的算法,每个线程拿到的时间片是动态的,进程数在分配的时间片在可变区间的情况下会直接影响到线程时间片的长短,所以100个线程每个线程的时间片在一定条件下会要比10个线程情况下的要短,也就意味着在相同时间里,前者的上下文切换次数是比后者要多的,所以可以得出一个结论:协程并发模型比多线程同步模型在一定条件下会减少线程切换次数(时间片有固定的范围,如果超出这个范围的边界则线程的时间片无差异),增加了协程切换次数,由于协程的切换是由程序员自己调度的,所以很难说协程切换的代价比省去的线程切换代价小,合理的方式应该是通过测试工具在具体的业务场景得出一个最好的平衡点。

2)从异步回调模型切到协程模型来看,从一些已有协程库的实现来看,协程的同步写法却有异步性能其实还是异步回调在支撑这个事情,所以我认为协程模型是在异步模型之上的东西,考虑到本身协程上下文切换的开销(其实很小)和数据结构调用的一些开销,理论上协程是比异步回调的性能要稍微差一点,但是可以处于几乎持平的性能,因为协程实现的代价非常小。

3)从一些异步驱动库的角度来看协程的话,因为异步框架把代码封装到很多个小类里面,然后串起来,这中间会涉及相当多的内存分配,而数据大都在离散的堆内存里面,而协程风格的代码,可以简单理解为一个简洁的连续空间的栈内存池,辅助数据结构也很少,所以协程可能会比厚重的封装性能会更好一些,但是这里的前提是,协程库能实现异步驱动库所需要的功能,并把它封装到同步调用里。

分享视频有C/C++,Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等等。

有关于c++ Linux后台服务器开发的相关视频


分享到:


相關文章: