协程库Libtask源码分析之架构篇

开发 架构
本文介绍libtask的基础原理。我们从libtask的main函数开始,这个main函数就是我们在c语言中使用的c函数,libtask本身实现了main这个函数,用户使用libtask时,要实现的是taskmain函数。taskmain和main的函数声明是一样的。下面我们看一下main函数。

 [[382125]]

本文转载自微信公众号「编程杂技」,作者theanarkh 。转载本文请联系编程杂技公众号。

前言:假设读者已经了解了协程的概念,实现协程的底层技术支持。本文会介绍基于底层基础,如何实现协程以及协程的应用(更多基础可以点击这里[1])。

libtask是google大佬Russ Cox(Go的核心开发者)所写,本文介绍libtask的基础原理。我们从libtask的main函数开始,这个main函数就是我们在c语言中使用的c函数,libtask本身实现了main这个函数,用户使用libtask时,要实现的是taskmain函数。taskmain和main的函数声明是一样的。下面我们看一下main函数。

  1. int main(int argc, char **argv) 
  2.     struct sigaction sa, osa; 
  3.     // 注册SIGQUIT信号处理函数 
  4.     memset(&sa, 0, sizeof sa); 
  5.     sa.sa_handler = taskinfo; 
  6.     sa.sa_flags = SA_RESTART; 
  7.     sigaction(SIGQUIT, &sa, &osa); 
  8.  
  9.     // 保存命令行参数 
  10.     argv0 = argv[0]; 
  11.     taskargc = argc; 
  12.     taskargv = argv; 
  13.  
  14.     if(mainstacksize == 0) 
  15.         mainstacksize = 256*1024; 
  16.     // 创建第一个协程 
  17.     taskcreate(taskmainstart, nil, mainstacksize); 
  18.     // 开始调度 
  19.     taskscheduler(); 
  20.     fprint(2, "taskscheduler returned in main!\n"); 
  21.     abort(); 
  22.     return 0; 

main函数主要的两个逻辑是taskcreate和taskscheduler函数。我们先来看taskcreate。

  1. int taskcreate(void (*fn)(void*), void *arg, uint stack) 
  2.     int id; 
  3.     Task *t; 
  4.  
  5.     t = taskalloc(fn, arg, stack); 
  6.     taskcount++; 
  7.     id = t->id; 
  8.     // 记录位置 
  9.     t->alltaskslot = nalltask; 
  10.     // 保存到alltask中 
  11.     alltask[nalltask++] = t; 
  12.     // 修改状态为就绪,可以被调度,并且加入到就绪队列 
  13.     taskready(t); 
  14.     return id; 

taskcreate首先调用taskalloc分配一个表示协程的结构体Task。我们看看这个结构体的定义。

  1. struct Task 
  2.     char    name[256];    // offset known to acid 
  3.     char    state[256]; 
  4.     // 前后指针 
  5.     Task    *next
  6.     Task    *prev; 
  7.     Task    *allnext; 
  8.     Task    *allprev; 
  9.     // 执行上下文 
  10.     Context    context; 
  11.     // 睡眠时间 
  12.     uvlong    alarmtime; 
  13.     uint    id; 
  14.     // 栈信息 
  15.     uchar    *stk; 
  16.     uint    stksize; 
  17.     //是否退出了 
  18.     int    exiting; 
  19.     // 在alltask的索引 
  20.     int    alltaskslot; 
  21.     // 是否是系统协程 
  22.     int    system; 
  23.     // 是否就绪状态 
  24.     int    ready; 
  25.     // 入口函数 
  26.     void    (*startfn)(void*); 
  27.     // 入口参数 
  28.     void    *startarg; 
  29.     // 自定义数据 
  30.     void    *udata; 
  31. }; 

接着看看taskalloc的实现。

  1. // 分配一个协程所需要的内存和初始化某些字段 
  2. static Task* 
  3. taskalloc(void (*fn)(void*), void *arg, uint stack) 
  4.     Task *t; 
  5.     sigset_t zero; 
  6.     uint x, y; 
  7.     ulong z; 
  8.  
  9.     /* allocate the task and stack together */ 
  10.     // 结构体本身的大小+栈大小 
  11.     t = malloc(sizeof *t+stack); 
  12.     memset(t, 0, sizeof *t); 
  13.     // 栈的内存位置 
  14.     t->stk = (uchar*)(t+1); 
  15.     // 栈大小 
  16.     t->stksize = stack; 
  17.     // 协程id 
  18.     t->id = ++taskidgen; 
  19.     // 协程工作函数和参数 
  20.     t->startfn = fn; 
  21.     t->startarg = arg; 
  22.  
  23.     /* do a reasonable initialization */ 
  24.     memset(&t->context.uc, 0, sizeof t->context.uc); 
  25.     sigemptyset(&zero); 
  26.     // 初始化uc_sigmask字段为空,即不阻塞信号 
  27.     sigprocmask(SIG_BLOCK, &zero, &t->context.uc.uc_sigmask); 
  28.  
  29.     /* must initialize with current context */ 
  30.     // 初始化uc字段 
  31.     getcontext(&t->context.uc)  
  32.     // 设置协程执行时的栈位置和大小 
  33.     t->context.uc.uc_stack.ss_sp = t->stk+8; 
  34.     t->context.uc.uc_stack.ss_size = t->stksize-64; 
  35.     z = (ulong)t; 
  36.     y = z; 
  37.     z >>= 16;    /* hide undefined 32-bit shift from 32-bit compilers */ 
  38.     x = z>>16; 
  39.     // 保存信息到uc字段 
  40.     makecontext(&t->context.uc, (void(*)())taskstart, 2, y, x); 
  41.  
  42.     return t; 

taskalloc函数代码看起来很多,但是逻辑不算复杂,就是申请Task结构体所需的内存和执行时栈的内存,然后初始化各个字段。这样,一个协程就诞生了。接着执行taskready把协程加入就绪队列。

  1. // 修改协程的状态为就绪并加入就绪队列 
  2. void taskready(Task *t) 
  3.     t->ready = 1; 
  4.     addtask(&taskrunqueue, t); 
  5.  
  6. // 把协程插入队列中,如果之前在其他队列,则会被移除 
  7. void addtask(Tasklist *l, Task *t) 
  8.     if(l->tail){ 
  9.         l->tail->next = t; 
  10.         t->prev = l->tail; 
  11.     }else
  12.         l->head = t; 
  13.         t->prev = nil; 
  14.     } 
  15.     l->tail = t; 
  16.     t->next = nil; 

taskrunqueue记录了所有就绪的协程。创建了协程并加入队列后,协程还没有开始执行,就像操作系统的进程和线程一样,需要有一个调度器来调度执行。下面我们看看调度器的实现。

  1. // 协程调度中心 
  2. static void taskscheduler(void) 
  3.     int i; 
  4.     Task *t; 
  5.     for(;;){ 
  6.         // 没有用户协程了,则退出 
  7.         if(taskcount == 0) 
  8.             exit(taskexitval); 
  9.         // 从就绪队列拿出一个协程 
  10.         t = taskrunqueue.head; 
  11.         if(t == nil){ 
  12.             fprint(2, "no runnable tasks! %d tasks stalled\n", taskcount); 
  13.             exit(1); 
  14.         } 
  15.         // 从就绪队列删除该协程 
  16.         deltask(&taskrunqueue, t); 
  17.         t->ready = 0; 
  18.         // 保存正在执行的协程 
  19.         taskrunning = t; 
  20.         // 切换次数加一 
  21.         tasknswitch++; 
  22.         // 切换到t执行,并且保存当前上下文到taskschedcontext(即下面要执行的代码) 
  23.         contextswitch(&taskschedcontext, &t->context); 
  24.         // 执行到这说明没有协程在执行(t切换回来的),置空 
  25.         taskrunning = nil; 
  26.         // 刚才执行的协程t退出了 
  27.         if(t->exiting){ 
  28.             // 不是系统协程,则个数减一 
  29.             if(!t->system) 
  30.                 taskcount--; 
  31.             // 当前协程在alltask的索引 
  32.             i = t->alltaskslot; 
  33.             // 把最后一个协程换到当前协程的位置,因为他要退出了 
  34.             alltask[i] = alltask[--nalltask]; 
  35.             // 更新被置换协程的索引 
  36.             alltask[i]->alltaskslot = i; 
  37.             // 释放堆内存 
  38.             free(t); 
  39.         } 
  40.     } 

调度器的代码看起来很多,但是核心逻辑就三个 1 从就绪队列中拿出一个协程t,并把t移出就绪队列 2 通过contextswitch切换到协程t中执行 3 协程t切换回调度中心,如果t已经退出,则修改数据结构,然后回收他占据的内存。如果t没退出,则继续调度其他协程执行。至此,协程就开始跑起来了。并且也有了调度系统。这里的调度机制是比较简单的,就是按着先进先出的方式就绪调度,并且是非抢占的。即没有按时间片调度的概念,一个协程的执行时间由自己决定,放弃执行的权力也是自己控制的,当协程不想执行了可以调用taskyield让出cpu。

  1. // 协程主动让出cpu 
  2. int taskyield(void) 
  3.     int n; 
  4.     // 当前切换协程的次数 
  5.     n = tasknswitch; 
  6.     // 插入就绪队列,等待后续的调度 
  7.     taskready(taskrunning); 
  8.     taskstate("yield"); 
  9.     // 切换协程 
  10.     taskswitch(); 
  11.     // 等于0说明当前只有自己一个协程,调度的时候tasknswitch加一,所以这里减一 
  12.     return tasknswitch - n - 1; 
  13.  
  14. /* 
  15.     切换协程,taskrunning是正在执行的协程,taskschedcontext是调度协程(主线程)的上下文, 
  16.     切换到调度中心,并保持当前上下文到taskrunning->context 
  17. */ 
  18. void taskswitch(void) 
  19.     needstack(0); 
  20.     contextswitch(&taskrunning->context, &taskschedcontext); 
  21.  
  22. // 真正切换协程的逻辑 
  23. static void contextswitch(Context *from, Context *to
  24.     if(swapcontext(&from->uc, &to->uc) < 0){ 
  25.         fprint(2, "swapcontext failed: %r\n"); 
  26.         assert(0); 
  27.     } 

yield的逻辑也很简单,因为协程在执行的时候,是不处于就绪队列的,当协程准备让出cpu时,协程首先把自己重新加入到就绪队列,等待下次被调度执行。当然我们也可以直接调度contextswitch切换到其他协程。重点在于什么时候应该让出cpu,又什么时候应该被调度执行。接下来会详细讲解。至此,我们已经有了支持协程所需要的底层基础。我们看到这个实现的思路也不是很复杂,首先有一个队列表示待执行的的协程,每一个协程对应一个Task结构体。然后调度中心不断地按照先进先出的方式去调度协程的执行就可以。因为没有抢占机制,所以调度中心是依赖协程本身去驱动的,协程需要主动让出cpu,把上下文切换回调度中心,调度中心才能进行下一轮的调度。接下来我们看看,基于这些底层基础,如果实现一个基于协程的服务器。下面我们通过一个例子进行讲解。

  1. void 
  2. taskmain(int argc, char **argv) 
  3.     // 启动一个tcp服务器 
  4.     if((fd = netannounce(TCP, 0, atoi(argv[1]))) < 0){ 
  5.         // ... 
  6.     } 
  7.     // 改为非阻塞模式 
  8.     fdnoblock(fd); 
  9.     // accept成功后创建一个客户端协程 
  10.     while((cfd = netaccept(fd, remote, &rport)) >= 0){ 
  11.         taskcreate(proxytask, (void*)cfd, STACK); 
  12.     } 

我们刚才讲过taskmain是我们需要实现的函数,首先通过netannounce建立一个tcp服务器。接着把fd改成非阻塞的,这个非常重要,因为在后面调用accept的时候,如果是阻塞的文件描述符,那么就会引起进程挂起,而非阻塞模式下,操作系统会返回EAGAIN的错误码,通过这个错误码我们可以决定下一步做什么。我们看看netaccept的实现。

  1. // 处理(摘下)连接 
  2. int 
  3. netaccept(int fd, char *server, int *port) 
  4.     int cfd, one; 
  5.     struct sockaddr_in sa; 
  6.     uchar *ip; 
  7.     socklen_t len; 
  8.     // 注册事件到epoll,等待事件触发 
  9.     fdwait(fd, 'r'); 
  10.     len = sizeof sa; 
  11.     // 触发后说明有连接了,则执行accept 
  12.     if((cfd = accept(fd, (void*)&sa, &len)) < 0){ 
  13.         return -1; 
  14.     } 
  15.     // 和客户端通信的fd也改成非阻塞模式 
  16.     fdnoblock(cfd); 
  17.     one = 1; 
  18.     setsockopt(cfd, IPPROTO_TCP, TCP_NODELAY, (char*)&one, sizeof one); 
  19.     return cfd; 

netaccept就是通过调用accept逐个处理tcp连接,但是在accept之前,有一个非常重要的操作fdwait。

  1. // 协程因为等待io需要切换 
  2. void fdwait(int fd, int rw) 
  3. {     
  4.     // 是否已经初始化epoll 
  5.     if(!startedfdtask){ 
  6.         startedfdtask = 1; 
  7.         epfd = epoll_create(1); 
  8.         // 没有初始化则创建一个协程,做io管理 
  9.         taskcreate(fdtask, 0, 32768); 
  10.     } 
  11.     struct epoll_event ev = {0}; 
  12.     // 记录事件对应的协程和感兴趣的事件 
  13.     ev.data.ptr = taskrunning; 
  14.     switch(rw){ 
  15.     case 'r'
  16.         ev.events |= EPOLLIN | EPOLLPRI; 
  17.         break; 
  18.     case 'w'
  19.         ev.events |= EPOLLOUT; 
  20.         break; 
  21.     } 
  22.  
  23.     int r = epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); 
  24.     // 切换到其他协程,等待被唤醒 
  25.     taskswitch(); 
  26.     // 唤醒后函数刚才注册的事件 
  27.     epoll_ctl(epfd, EPOLL_CTL_DEL, fd, &ev); 

fdwait首先把fd注册到epoll中,然后把协程切换到下一个待执行的协程。这里有个细节,当协程X被调度执行的时候,他是脱离了就绪队列的,而taskswitch函数只是实现了切换上下文到调度中心,调度中心会从就绪队列从选择下一个协程执行,那么这时候,脱离就绪队列的协程X就处于孤岛状态,看起来再也无法给调度中心选中执行,这个问题的处理方式是,把协程、fd和感兴趣的事件信息一起注册到epoll中,当epoll监听到某个fd的事件发生时,就会把对应的协程加入就绪队列,这样协程就可以被调度执行了。在fdwait函数一开始那里处理了epoll相关的逻辑。epoll的逻辑也是在一个协程中执行的,但是epoll所在协程和一般协程不一样,类似于操作系统的内核线程一样,epoll所在的协程成为系统协程,即不是用户定义的,而是系统定义的。我们看一下实现

  1. void fdtask(void *v) 
  2.     int i, ms; 
  3.     Task *t; 
  4.     uvlong now; 
  5.     // 变成系统协程 
  6.     tasksystem(); 
  7.     struct epoll_event events[1000]; 
  8.     for(;;){ 
  9.         /* let everyone else run */ 
  10.         // 大于0说明还有其他就绪协程可执行,则先让给他们执行,否则往下执行 
  11.         while(taskyield() > 0) 
  12.             ; 
  13.         /* we're the only one runnable - poll for i/o */ 
  14.         errno = 0; 
  15.         // 没有定时事件则一直阻塞 
  16.         if((t=sleeping.head) == nil) 
  17.             ms = -1; 
  18.         else
  19.             /* sleep at most 5s */ 
  20.             now = nsec(); 
  21.             if(now >= t->alarmtime) 
  22.                 ms = 0; 
  23.             else if(now+5*1000*1000*1000LL >= t->alarmtime) 
  24.                 ms = (t->alarmtime - now)/1000000; 
  25.             else 
  26.                 ms = 5000; 
  27.         } 
  28.         int nevents; 
  29.         // 等待事件发生,ms是等待的超时时间 
  30.         if((nevents = epoll_wait(epfd, events, 1000, ms)) < 0){ 
  31.             if(errno == EINTR) 
  32.                 continue
  33.             fprint(2, "epoll: %s\n", strerror(errno)); 
  34.             taskexitall(0); 
  35.         } 
  36.  
  37.         /* wake up the guys who deserve it */ 
  38.         // 事件触发,把对应协程插入就绪队列 
  39.         for(i=0; i<nevents; i++){ 
  40.             taskready((Task *)events[i].data.ptr); 
  41.         } 
  42.  
  43.         now = nsec(); 
  44.         // 处理超时事件 
  45.         while((t=sleeping.head) && now >= t->alarmtime){ 
  46.             deltask(&sleeping, t); 
  47.             if(!t->system && --sleepingcounted == 0) 
  48.                 taskcount--; 
  49.             taskready(t); 
  50.         } 
  51.     } 

我们看到epoll的处理逻辑和一般服务器的类似,通过epoll_wait阻塞,然后epoll_wait返回时,处理每一个发生的事件,而且libtask还支持超时事件。另外libtask中当还有其他就绪协程的时候,是不会进入epoll_wait的,它会把cpu让给就绪的协程(通过taskyield函数),当就绪队列只有epoll所在的协程时才会进入epoll的逻辑。至此,我们看到了libtask中如何把异步变成同步的。当用户要调用一个可能会引起进程挂起的接口时,就可以调用libtask提供的一个相应的API,比如我们想读一个文件,我们可以调用libtask的fdread。

  1. int 
  2. fdread(int fd, void *buf, int n) 
  3.     int m; 
  4.     // 非阻塞读,如果不满足则再注册到epoll,参考fdread1 
  5.     while((m=read(fd, buf, n)) < 0 && errno == EAGAIN) 
  6.         fdwait(fd, 'r'); 
  7.     return m; 

这样就不需要担心进程被挂起,同时也不需要处理epoll相关的逻辑(注册事件,事件触发时的处理等等)。异步转同步,libtask的方式就是通过提供对应的API,先把用户的fd注册到epoll中,然后切换到其他协程,等epoll监听到事件触发时,就会把对应的协程插入就绪队列,当该协程被调度中心选中执行时,就会继续执行剩下的逻辑而不会引起进程挂起,因为这时候所等待的条件已经满足。

总结:libtask的设计思想就是把业务逻辑封装到一个个协程中,由libtask实现协程的调度,在各个业务逻辑中进行切换,从而驱动着系统的运行。另外libtask也提供了一个网络和文件io异步变同步的解决方案。使得我们使用起来更加方便,高效。今天先讲到这里。

References

[1] 更多基础可以点击这里: https://github.com/theanarkh/read-libtask-code/blob/main/README.md

 

责任编辑:武晓燕 来源: 编程杂技
相关推荐

2021-02-20 06:09:46

libtask协程锁机制

2023-04-19 21:20:49

Tars-Cpp协程

2021-05-20 09:14:09

Kotlin协程挂起和恢复

2021-09-16 09:59:13

PythonJavaScript代码

2021-08-04 16:19:55

AndroidKotin协程Coroutines

2022-09-12 06:35:00

C++协程协程状态

2023-11-17 11:36:59

协程纤程操作系统

2023-07-13 08:06:05

应用协程阻塞

2023-12-05 13:46:09

解密协程线程队列

2023-10-24 19:37:34

协程Java

2021-12-09 06:41:56

Python协程多并发

2014-02-11 09:28:57

2021-05-21 08:21:57

Go语言基础技术

2022-09-06 20:30:48

协程Context主线程

2023-08-08 07:18:17

协程管道函数

2016-10-28 17:39:47

phpgolangcoroutine

2024-02-05 09:06:25

Python协程Asyncio库

2023-11-04 20:00:02

C++20协程

2017-05-02 11:38:00

PHP协程实现过程

2020-11-29 17:03:08

进程线程协程
点赞
收藏

51CTO技术栈公众号