成年人应该懂得的协程小知识

[补档]成年人应该懂得的协程小知识

1.协程简介

协程相信大家都不陌生,他似乎有一种魔力,原来异步式的支离破碎的代码,引入协程以后,可以改写成同步式.可以大大改善开发效率.可以说是居家旅行必备.
了解协程可能需要了解一些操作系统的知识,了解至少一个编程语言的内存模型.

2. 进程与线程

在介绍协程之前,不得不先说一下进程和线程.他们是操作系统分配资源和运行时间片的最小单位.

2.1 内核调度实体(Kernel Scheduling Entity, KSE)

linux下内核分配CPU执行的最小单位是KSE,即内核调度实体. 在早期还没有线程这个概念的时候,通常是使用多进程来实现服务器的高并发.后来引入了线程.(跑个题,引入线程出现了大量的线程安全问题.)其实linux内核并不区分执行单位是线程还是进程.linux执行单位是KSE. 
pthread里的线程和KSE是1:1的关系,所以线程也就是最小执行单位.

2.1.1 fork 与clone

do_fork系统api可选的可以共享父进程的一些资源,不共享的部分会复制一份成为新进程的资源.而最常见的fork()函数则不会共享任何资源(通常还有COW写时复制的优化).
pthread的实现是使用linux内核提供的__clone系统调用.本质上还是调用do_fork(),但是它共享了父进程基本上所有的资源,但是自己会拥有一个独立的栈.pthread线程的本质其实也是轻量级进程.

2.2 地址与栈

我们知道进程是资源分配的最小单位.同一个进程里, 多个线程共同享有相同的地址空间(全局变量,堆),以及其他资源,比如file,信号等. 每个线程私有的属性有寄存器,errno,线程栈等.但是线程并没有独立的地址空间,所以不同线程栈分布在进程里的同一个地址空间里的不同位置.
也就是说,倘若某个线程的栈溢出了,就会可能破坏掉别的线程栈,从而影响到别的线程的运行.

2.2.1 线程栈

x86下linux线程栈是一个地址从高到低增长的先进后出的数据结构.线程栈的大小一般在8M~10M.所以我们不能一下子在栈中申请太多的内存,栈会爆掉.另一种情况是,随着函数的嵌套调用,栈指针会慢慢往下走.当发生调用函数过多(大部分情况是死循递归环调用),也会出现段错误的爆栈情况.

void fn() {
    int a[100];
    fn();
}

int main() {
   fn();
   return 0; 
}

这是一个死循环的递归, 每执行一次function,栈指针会往下指,也就是开辟一段空间执行程序(主要是开辟数组a),栈的空间容量就少了一些, 随着递归次数的增加,栈就爆了.

相信很多小伙伴都实现过正则表达式引擎.教科书的语法分析方法,使用了递归下降来构造语法树,这里就一定要考虑到栈的问题.当正则表达式一大,就容易爆栈.

3. 协程原理

3.1 调度原理

3.1.0 协程栈

每个协程依然是运行在线程上的,协程可以自己指定一块地址作为运行栈空间。这个栈空间用于分配c语言函数运行所需要的栈变量。在有些实现里, 所有的协程指定的运行栈位置是同一地址。每个协程还需要自带一块内存区域,用于保存现场。协程有时间片的时候,把运行栈的内容还原成自己备份的然后运行,让出时间片之前,保存栈内容到自己的备份区域。 
举个例子,所有基佬都想洗澡, 但是只有一间单人浴室。协程好比基佬,浴室就是这个运行栈。他们是多对一的关系。

每个基佬只洗几秒钟, 就自觉让出浴室,让下一个人洗。好比协程的让出时间片。

浴室是公共的,但是肥皂是自己的,每个人洗完肥皂要自己带回去,继续洗的时候,带自己的肥皂。 好比协程的还原现场。 
除了栈以外,协程切换所需要保存到上下文,swapcontext都为我们做好了,所以任务调度只需要备份还原栈,再调用swapcontext就ok撸,是不是很方便?

3.1.1协程的调度节约了IO等待时间

简单地讲,先考虑单线程的情况.假设我们有三个IO密集型任务,任务A,任务B,任务C. 在不使用协程的情况下,完成他们的总耗时就是A+B+C的时间;在使用协程的情况下,A和B和C可以近似"并发"地一起执行, 总共耗时可能会小于A+B+C的时间. 在A,B,C任务不存在IO等待,只是单纯CPU计算任务的时候,使用协程并不能提高效率,反而因为调度(栈复制)的开销而丧失一些性能,反而会更慢.

3.1.2为什么协程能节约IO等待时间

在不引入协程的情况下,使用c语言一般只能线性执行A,B,C三个任务.即A执行完了再执行B,再执行C. 但其实如果是IO密集型的任务,ABC任务会有大量时间在等待IO,这时候CPU利用率低,非常浪费.于是可以在A等待IO的时候,将其现场保存下来,然后去执行别的任务,过一段时间再回来看看,如果IO就绪就继续执行A,如果没就绪再切到别的任务上.就好比厨师只有一个人,如果先烧水,等水烧开了,再切菜,就有点浪费时间. 引入协程就可以先把水烧着,然后不管他,厨师这时候去切菜,,切几下就去观察一下水的情况,等切好菜了水也就差不多烧开了. 

3.2 协程的使用

我理想中的协程库,接口尽可能简单,用法可以类似多线程或者goroutine那样.

//伪代码
int main() {
    Schedule s;
    s.go([] (Coroutine::Ptr co) {
        std::cout<<"1"<<"\n";
        co->yield();
        std::cout<<"2"<<"\n";
        co->yield();
        std::cout<<"3"<<"\n";
        co->yield();
    });
    
    s.go([] (Coroutine::Ptr co) {
        std::cout<<"A"<<"\n";
        co->yield();
        std::cout<<"B"<<"\n";
        co->yield();
        std::cout<<"C"<<"\n";
        co->yield();
    });
    s.run();
    return 0;
}

那么控制台输出结果应该会是1 A 2 B 3 C 

举一个更有意义的例子.


std::size_t readbytes(int fd,  //文件描述符
    char* src,                  //缓冲区
    std::size_t len,            //src长度
    std::size_t read_bytes,    //read_bytes 需要读的字节数
    Coroutine::Ptr co) {       //协程
    int n = 0;
    std::size_t readn = 0;
    while (true) {
        n = ::read(fd, src, len - readn);
        if (n <= 0) {
            //处理错误
            if (errno == EAGAIN) {
                co->yield();
                continue;
            }
            //处理errno和eof
            //...
            return readn;
        }
        readn += static_cast<std::size_t>(n);
        src += n;
        if (readn == read_bytes || readn == len) {
            return readn;
        }
        //让出时间片
        co->yield();
    }
}

这样就大致用协程实现了一个读到n个字节以后再返回的函数.(这个函数没有错误处理)

3.2实现方式

协程的实现方式多种多样,在linux下,可以使用getcontext, 也可以使用c语言库的setjmp、longjmp实现协程.还有一些非常奇妙的使用switch case的.

3.2.1 getcontext makecontext swapcontext

首先先演示一个示例

#include <ucontext.h>  
#include <stdio.h>  
  
void co_func(void * arg)   {  
    std::cout<<"hello "; 
}  
void coroutine() {  
    char stack[1024*128];  
    ucontext_t child, main;  
    //初始化child
    getcontext(&child); 
    //栈地址
    child.uc_stack.ss_sp = stack;   
    child.uc_stack.ss_size = sizeof(stack);
    //栈大小  
    child.uc_stack.ss_flags = 0;  
    child.uc_link = &main;//设置后继上下文  
    //当切换到child的时候,会执行co_func()
    makecontext(&child,(void (*)(void))co_func,0); 
    //切换到child上下文,保存当前上下文到main  
    swapcontext(&main,&child);
    //执行完child会返回这里
    std::cout<<"world"<<std::endl; 
}  
  
int main() {  
    coroutine();  
    return 0;  
}  

最后会输出hello world. 
不想贴太多代码,刚好百度上也能搜到关于getcontext的大量使用说明,就不做详细介绍. 
更详细的关于getcontext使用的文章http://blog.csdn.net/qq910894904/article/details/41911175

3.2.2 汇编方式

http://www.cnblogs.com/sniperHW/archive/2012/06/19/2554574.html

3.3协程切换开销

3.3.1切换上下文开销

公共栈我们可以设定为10mb大小(我们尽量选择和线程栈大小一样), 但是运行只花了30kb,就只需要保存30kb,那么memcpy执行的时间就会很短. 在使用协程时,大对象尽可能从堆上申请,可以减少保存现场的内存复制的开销。因为堆上的数据不会被覆盖,(指向堆的指针)占用的栈空间非常小。 所以切换上下文开销 = memcpy + swapcontext的开销

3.3.2保存现场占用的内存

每个协程都自带一块内存区域用来保存运行的栈。这个空间会占用大量内存吗?根据我都使用经验,粗略估算是不会的。 运行栈设定为10m, 但是每个协程不会都用到这么大。有的可能是1m,有的可能是600k。那么备份区域的空间就可以是变长的。如果每个协程都带一个10m的备份空间,那几乎和多线程没啥区别了,8G的内存也只能跑8000个协程。 所以内存占用开销 = 主栈(10m) + n * 备份栈(0~10m), n是协程数量.

3.3.3 能否省去内存复制的cpu开销?

回答是可以.我们完全可以不设置公共运行栈,每个协程直接申请一段各自的栈,运行在自己的空间里,这样栈之间互相不影响,就不需要保存栈空间这么一段cpu开销了. 但这样会遇到一个问题,每个协程的栈大小是固定的,设多少合适?少了会爆,多了很浪费内存.好在go语言给了我们很大的启发,他的goroutine的栈可以一直扩容,可以说是动态的.但是黑魔法过多,只能仰望.

3.4 补充

3.4.1 阻塞调用是协程的天敌

协程的运行不可以有阻塞的函数在里面,否则线程阻塞了,调度程序也无能为力.会严重影响到性能.但是linux下许多系统调用是阻塞的,比如gethostbyname, 文件IO的读写等.有些可以通过hook的方式解决,有一些只能通过线程池来模拟异步.

//来自libgo的常用可能阻塞的系统调用列表.
//libgo对他们进行了hook处理
connect   
read      
readv     
recv      
recvfrom  
recvmsg   
write     
writev    
send      
sendto    
sendmsg   
poll      
select    
accept    
sleep     
usleep    
nanosleep
gethostbyname                                                               
gethostbyname2                                                              
gethostbyname_r                                                             
gethostbyname2_r                                                            
gethostbyaddr                                                               
gethostbyaddr_r
https://github.com/yyzybb537/libgo

3.4.2 不要跨协程引用栈上的变量

根据实现的不同,有些协程库会有保存栈和恢复栈的过程. 协程A在他的时间片对别的协程B栈上的变量进行改写, 等B运行的时候,会将保存的现场恢复,也就"回档"了, 修改的变量是不会生效的.很容易引起程序上的错误应该尽量避免.

3.4.3 内存泄露

协程"阻塞"在某个地方,迟迟不肯销毁自己的时候,就会发生内存泄露. 这个情况在go语言上发生的情况会比较多.许多操作者使用不当,就会造成goroutine一直在工作而不会被回收(常见情况是while(true) {//没有设置退出条件}). 有些恶意用户可能会通过大量的tcp连接来消耗服务器内存.

4. goroutine

goroutine绝对可以算精妙绝伦,让你大呼过瘾.

调度器

调度器核心由以下三个部分组成

  1. M(线程)
  2. P(核心数)
  3. G(goroutine任务)

M对象是运行时真正执行的单位.他其实就是线程.我们知道

未完待续

推荐阅读

雨痕的go笔记 https://github.com/qyuhen/book,
为什么goroutine的栈是无线扩充的? https://dave.cheney.net/2013/06/02/why-is-a-goroutines-stack-infinite

猜你喜欢

转载自www.cnblogs.com/sunfishgao/p/10391232.html