linux源码解读(二十七):协程原理和背景(一)& java的协程实现

  1、协程原理阐述

     (1)为了提升数据处理的效率,用户的应用程序通常采用多线程的形式,典型的就是生产者-消费者模型:生产者往共享内存块写数据,消费者从共享内存块读数据后处理!这种经典的模型具体落地实现时有两点需要特别注意:

  • 多线程之间的互斥/同步:一般情况下共享内存块同时只能有1个线程写,写线程之间必须互斥;写线程结束后多个读线程可以同时读;但是如果读线程不能处理相同的数据,为了避免不同读线程重复读取同样的数据,读线程之间也要互斥;这么一来会导致写线程之间、读线程之间都要互斥,在一定程度上影响了效率
  • 线程切换:不同线程切换需要操作系统内核来实现,这就需要通过syscall进入系统内核,线程切换完后又要切出系统内核回到3环的应用程序继续执行,从3环到0环内核的一进一出也会有上下文切换带来的性能损耗

        仔细想想不难发现,带来损耗的无非这两点:线程锁、线程之间切换;如果想办法去掉这两个,是不是效率就大幅提升了? 协程就在这种背景下诞生了!

     (2)当初为了提升数据的处理效率发明了多线程,但多线程也伴随产生了上述问题。此时如果摒弃多线程,重新回到单线程的方式,该怎么做了?只剩华山一条路了:单个线程即生产、又消费(又当爹、又当妈的,真是幸苦了!)!这么一来完全避免了多线程的切换、锁带来的问题,岂不美哉? 剩下的问题就变成这几个了:

  •  线程什么执行生产者代码了?什么时候执行消费者代码了?
  •  谁来控制执行代码的切换了?什么时候执行切换代码了?
  •  怎么切换需要执行的代码了?

     (3)由于是单线程,不涉及到线程切换和锁,所以协程是不需要操作系统内核参与的,整个协程功能的实现都是在3环应用层,所以操作系统并未提供现成的协程方案和代码库。先来看看python实现的最简单协程代码demo,如下:

import time

def consumer():
    r = ''
    while True:
        n = yield r
        if not n:
            return
        print('[CONSUMER] Consuming %s...' % n)
        time.sleep(1)
        r = '200 OK'

def produce(c):
    c.next()
    n = 0
    while n < 5:
        n = n + 1
        print('[PRODUCER] Producing %s...' % n)
        r = c.send(n)
        print('[PRODUCER] Consumer return: %s' % r)
    c.close()

if __name__=='__main__':
    c = consumer()
    produce(c)

  先看看代码的执行效果:生产1个、消费1个,整个流程井然有序,丝毫不混乱!

       

  整个代码只有1个线程,就是main函数开始的地方。consumer 函数是一个 generator,把 consumer 传入 produce 后:首先调用 c.next()启动生成器;而后一旦生产了东西,通过 c.send(n)切换到 consumer 执行;consumer 通过 yield 拿到消息;处理完毕后又通过 yield 把结果传回;produce 拿到 consumer 处理的结果,继续生产下一条消息;produce 决定不生产了,通过 c.close()关闭 consumer,整个过程结束。整个流程无锁,由一个线程执行,produce 和 consumer 协作完成任务,所以称为“协程”,而非线程的抢占式多任务。总结一下这个demo案例的特点:

  •  生产者和消费者之间的切换是用户自己控制的,和操作系统无关
  •    切换时显示地调用了send(为统一表示,后续用resume替换)、yield函数,通过这两个函数控制切换的时机! 

       通过上述简单的demo用例,协程的原理应该理解了吧?最大的特点:单线程+用户程序自行通过yield+resume在生产者和消费者代码之间切换,与操作系统内核无关,也不需要内核参与!就目前这个demo案例仔细推敲,还是有新问题:上述这种demo的应用场景在哪了?在实际生产环境中,难道真的有人这样写代码?哪个场景适合用协程了?

       2、上面只介绍了协程表面的特点,实际上还未触及协程的本质:代码执行不下去时不能傻等空转、浪费cpu时间片!举个例子:代码是从main开始执行的,而main里面最开始实行的是comsumer消费者函数,这就奇怪了:生产者都还没执行,数据都没生产出来,此时执行消费者有用么?所以consumer此刻执行yield跳转到生产者代码,本质就是在当前条件不成熟、代码执行不下去的时候让出cpu,跳转到条件成熟、可以继续执行的代码处!这个跳转的时机和跳转的地点是应用程序的开发者人为控制的(因为操作系统不参与,所以没有提供统一的“标准”接口,市面上有各种协程的实现代码!看到这个本质,是不是很容易联想到另一个应用场景了:网络IO多路复用!

    (1)再来回顾一下server最早网络通信代码的写法,如下:

while(1) {
    socklen_t len = sizeof(struct sockaddr_in);
    int clientfd = accept(sockfd, (struct sockaddr*)&remote, &len);

    pthread_t thread_id;
    pthread_create(&thread_id, NULL, client_cb, &clientfd);
}

  在死循环里阻塞在了accept这里;如果此时有客户端连接,立马单独生成一个线程来处理这个新的连接请求,以及后续的数据收发;这种代码的逻辑思路清晰,后续维护也很容易,不过对性能的损耗是很大的:每次从客户端来个请求都要生成一个线程,假如每个线程的栈是1MB,再不考虑线程结构体本身内存消耗的前提下,1000个请求就要耗费1GB内存;10万个请求就要耗费100G内存,再加上线程结构体本身的存储空间,内存消耗就更大了;然而这还只是内存的消耗,还有线程切换的时间成本了?要进入内核切换,这些都非常耗时!为了解决这些问题,异步和IO多路复用被发明了出来,本质核心思路是:当前客观条件不满足、无法继续执行代码时,让出cpu,当前线程开始等待;当前客观条件成熟、可以继续执行代码时唤醒线程继续执行!代码框架如下:

       

      把socket/fd通过epoll_ctl加入红黑树管理;一旦注册的事件发生(比如收到数据、有新客户端连接、有数据需要发送等),可以快速通过fd从红黑树找到该epollitem返回给epoll_wait,然后进入for循环挨个处理这些事件,这样做的好处:

  •      把以前的同步、阻塞改成了异步、非阻塞
  •      把以前的多线程改成了单线程,也不涉及到线程切换和锁了!

      貌似epoll的核心功能和协程是一样的呀,既然都用epoll解决多线程切换、锁、同步阻塞的问题了,还要协程干啥了

 (2) 仔细看,其实上述epoll+单线程的方式并不完美,还是有缺陷,比如:在接收到数据时要赶紧处理吧。万一收到的数据量很大,处理的逻辑又很复杂,非常耗时,整个线程是不是又被“卡住”了(类似的场景还有中断:为了避免中断嵌套,处理中断请求时要关闭中断的,所以中断的handler不能耗时太长,否则可能造成新来的中断被忽视丢弃?好不容易通过epoll解决了accept、recv等方法的阻塞,现在却又卡在了数据处理上,咋办? 通过在处理数据这里重新生成新线程的方式来避免阻塞原来的主线程(专业名称叫回调函数)?比如下面这种写法:

while (1) {
    int nready = epoll_wait(epfd, events, EVENT_SIZE, -1);

    for (i = 0;i < nready;i ++) {

        int sockfd = events[i].data.fd;
        if (sockfd == listenfd) {
            int connfd = accept(listenfd, xxx, xxxx);
            
            setnonblock(connfd);

            ev.events = EPOLLIN | EPOLLET;
            ev.data.fd = connfd;
            epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);

        } else {
            handle(sockfd);
        }
    }
}
int thread_cb(int sockfd) {
    // 此函数是在线程池创建的线程中运行。
    // 与handle不在一个线程上下文中运行
    recv(sockfd, rbuffer, length, 0);
    parser_proto(rbuffer, length);
    send(sockfd, sbuffer, length, 0);
}

int handle(int sockfd) {
    //此函数在主线程 main_thread 中运行
    //在此处之前,确保线程池已经启动。
    push_thread(sockfd, thread_cb); //将sockfd放到其他线程中运行(这里单独生成了线程池)。
}

  这种写法的好处:Handle函数将sockfd处理方式放到另一个已经其他的线程中运行。如此做法,将io操作(recv,send)与epoll_wait 不在一个处理流程里面,使得io操作(recv,send)与epoll_wait实现解耦,能避免server程序依赖epoll_wait的循环响应速度变慢;但还是有缺点:

  •       由于每次处理数据都要调用线程,对cpu和内存的消耗又变大了,而且又涉及到多线程之间互斥/同步,绕来绕去怎么又回到原点了?
  •       每一个子线程都需要管理好sockfd,避免在IO操作的时候,sockfd出现关闭或其他异常。根本原因是:执行回调函数时的代码环境和当初触发回调时的环境可能不一样了,这种异步回调时的容错是需要开发人员自行考虑的

    IO同步和异步之间的对比如下:

对比项
IO同步操作
IO异步操作
Sockfd管理
管理方便
多个线程共同管理
代码逻辑
程序整体逻辑清晰
子模块handler逻辑清晰
程序性能
响应时间长性能差
响应时间短,性能好

     两种方式各有千秋,就不能优势互补、融合贯通一下:既有异步的性能优势,又有同步的代码逻辑清晰优势

     (3)再次回顾上个demo的缺陷:为了解决handler处理数据耗时太长而引入了线程池(本质是把同步处理变成了异步处理),而线程池又需要开发人员考虑sockfd关闭或异常等情况的容错,有没有其他办法替代线程池解决handler处理耗时的问题了?这就需要协程了!看看文章开头第一个协程的例子:同一个线程,通过yield和resume在生产者和消费者之间不停地切换(本质是在选择合适的代码执行),两边都不拉下,时机把握的刚刚好!把这个思路用到这里:handler处理数据耗时太长,导致线程“卡住”,耽误其他代码的执行(比如epoll_wait监听是否有事件发生),此时如果人为在handler加上yield,先跳转去执行epoll_wait;等到时机合适后再人为通过resume回到handler继续执行,图示如下:

          

        整个过程的特点:

  •   始终保持单线程,不涉及线程切换、互斥/同步等
  •        也不涉及异步回调,保持了同步的代码逻辑。handler代码执行的sockfd等重要的参数或环境没变!
  •        人为通过yield、resume来回切换避免阻塞,在单线程中达到了多线程才有的异步性能

    上述的理论分析头头是道,但是在网络IO场景下,协程该怎么落地了?      

  3、java的协程实现

  截至目前,java官方并未正式上线协程的实现框架,所以现阶段只能使用第三方的协程框架,诸如kilim、quasar、loom等;尽管实现方式不同,但远离都一样:自己有schedule在不同的代码来回切换!切换的时候context也要在内存保存,切回来的时候才能恢复执行。但是:代码切换(汇编层面就是jmp语句)都是在3环应用层自己控制的,不需要通过syscall进入0环内核再切换,所以不需要操作系统参与,效率高了不少!

    

总结:

1、由于协程是3环用户程序层面的切换,不需要操作系统参与,所以操作系统并没有提供统一的协程实现接口,所以截至目前协程也只是一种标准思路,而不是“标准库”!

2、epoll和协程是松耦合的,没有必然联系;只是在处网络IO请求的时候需要epoll来检测是否有事件到达。如果有,在通过协程调度器去执行。在处理非网络IO阻塞、读取文件等和网络无关的IO时,就用不上epoll了!

3、协程只适合IO密集型的任务进程,因为IO通常会伴随着大量的阻塞等待过程,而使用协程就可以在IO阻塞的同时让出CPU,而当IO就绪后再主动抢占CPU即可;

4、在单线程中:人为通过yield、resume等操作不停地寻找并执行可执行的代码,避免了代码不可执行时的阻塞、浪费cpu时间片的空转

 

参考:

1、https://www.bilibili.com/video/BV1a5411b7aZ/?spm_id_from=333.788.recommend_more_video  协程和epoll

2、https://www.zhihu.com/question/455735271/answer/2264510160

3、https://www.zhihu.com/question/503292770/answer/2332328285

4、https://www.zhihu.com/question/503292770/answer/2261532968 

5、https://www.zhihu.com/question/455735271

6、https://www.bilibili.com/video/BV1a5411b7aZ/?spm_id_from=333.788.recommend_more_video  epoll和协程

7、https://github.com/wangbojing/NtyCo/wiki/NtyCo%E7%9A%84%E5%AE%9E%E7%8E%B0

8、https://jordanzheng.github.io/simple-analysis-kilim/  java协程开源库

posted @ 2022-03-03 12:25  第七子007  阅读(686)  评论(0编辑  收藏  举报