• NtyCo纯C协程的原理分析


    一、协程的由来

      从IO同步和异步的优缺点分析如下:

      IO同步优点就是sockfd管理方便,操作逻辑清晰;缺点是程序依赖epoll_wait的循环响应速度,程序性能差。

      IO异步优点就是子模块好规划,程序性能高;缺点就是逻辑理解有点难度,还会出现多个线程共用一个sockfd,此时需要避免在IO操作时,出现sockfd出现关闭或其它异常。

      有没有一种方式,同步的方式实现了异步的性能呢?那就是下文所说的协程。

    二、协程切换的核心

      协程切换核心就是yield(让出)与resume(恢复)来实现协程上下文切换,实现有以下3种方法。

      (1)longjmp和setjmp

      (2)ucontext

      (3)汇编实现跳转

      本文使用第三种汇编实现,yied = switch(a,b),resume = switch(b,a),根据不同的处理器的汇编指令实现switch的操作,比如x64_86如下。

    1. _asm__(
    2. " .text \n"
    3. " .p2align 4,,15 \n"
    4. ".globl _switch \n"
    5. ".globl __switch \n"
    6. "_switch: \n"
    7. "__switch: \n"
    8. " movq %rsp, 0(%rsi) # 从rsp存到rsi寄存器 \n"
    9. " movq %rbp, 8(%rsi) # 移动8个字节,一个指针是8个字节 \n"
    10. " movq (%rsp), %rax # save insn_pointer \n"
    11. " movq %rax, 16(%rsi) \n"
    12. " movq %rbx, 24(%rsi) # save rbx,r12-r15 \n"
    13. " movq %r12, 32(%rsi) \n"
    14. " movq %r13, 40(%rsi) \n"
    15. " movq %r14, 48(%rsi) \n"
    16. " movq %r15, 56(%rsi) \n"
    17. " movq 56(%rdi), %r15 \n"
    18. " movq 48(%rdi), %r14 \n"
    19. " movq 40(%rdi), %r13 # restore rbx,r12-r15 \n"
    20. " movq 32(%rdi), %r12 \n"
    21. " movq 24(%rdi), %rbx \n"
    22. " movq 8(%rdi), %rbp # restore frame_pointer \n"
    23. " movq 0(%rdi), %rsp # restore stack_pointer \n"
    24. " movq 16(%rdi), %rax # restore insn_pointer \n"
    25. " movq %rax, (%rsp) \n"
    26. " ret \n"
    27. );
    28. //64位系统,一个指针是8个字节

     x86 _64 的寄存器有 16 个 64 位寄存器,分别是 :%rax, %rbx,%rcx, %esi, %edi, %rbp, %rsp, %r8, %r9,%r10, %r11, %r12, %r13, %r14, %r15 ,%rax。

      (1)%rax作为函数返回值使用的

      (2)%rsp 栈指针寄存器 指向栈顶

      (3)%rdi, %rsi, %rdx, %rcx, %r8, %r9 用作函数参数 依次对应第 1 参数 第 2 参数。。。

      (4)%rbx, %rbp, %r12, %r13, %r14, %r15用作数据存储

      协程上下文切换,就是将 CPU 的寄存器暂时保存,再将即将运行的协程的上下文寄存器分别mov到cpu对应的寄存器上。

    三、回调协程的子过程

        协程的上下文结构体

    1. typedef struct _nty_cpu_ctx {
    2. void *esp; //栈指针指向-->stack
    3. void *ebp;
    4. void *eip;//指向回调函数入口
    5. void *edi;//参数
    6. void *esi;
    7. void *ebx;
    8. void *r1;
    9. void *r2;
    10. void *r3;
    11. void *r4;
    12. void *r5;
    13. } nty_cpu_ctx;

      cpu中有一个非常重要的寄存器eip,用来存储cpu运行的下一条指令地址,可以把回调函数的地址存储到eip。

    四、协程定义

      一个协程核心结构体如下

    1. typedef struct _nty_coroutine {
    2. nty_cpu_ctx ctx;
    3. proc_coroutine func;
    4. void *arg;
    5. size_t stack_size;
    6. nty_coroutine_status status;
    7. nty_schedule *sched;
    8. uint64_t birth;
    9. uint64_t id;
    10. void *stack;
    11. RB_ENTRY(_nty_coroutine) sleep_node;
    12. RB_ENTRY(_nty_coroutine) wait_node;
    13. TAILQ_ENTRY(_nty_coroutine) ready_next;
    14. TAILQ_ENTRY(_nty_coroutine) defer_next;
    15. } nty_coroutine;

      (1)context,上下文,切换用的

      (2)stack,每个协程的栈,协程内部用来做函数压栈

      (3)size,协程栈的大小

      (4)func,协程入口函数

      (5)arg,入口函数的参数

      (6)wait(等待集),等待IO就绪,等待集合采用红黑树存储

      (7)sleep(睡眠树),采用红黑树存储,按睡眠时间进行排序,key为睡眠时长,value为协程节点

      (8)ready(就绪集合),采用队列ready_queue存储

      (9)status 状态

      协程有3种状态:就绪、睡眠、等待;新创建的协程,创建完成后,加入就绪集合,等待调度器的调度;协程在运行完成后,进行IO操作,此时IO并未准备好,进入等待状态集合;IO准备就绪,协程开始运行,后续进行sleep操作,此时进入到睡眠状态集合。

    推荐一个视频讲解:纯C语言|实现协程框架,底层原理与性能分析

    免费视频录播以及视频文档资料获取添加:Q群:720209036 点击加入~ 群文件共享

    ​五、调度器实现

      调度器主要实现协程的切换,当IO准备就绪时,切换到该IO对应的协程,调度器的结构体如下。

    1. typedef struct _nty_coroutine_queue nty_coroutine_queue;
    2. typedef struct _nty_coroutine_rbtree_sleep nty_coroutine_rbtree_sleep;
    3. typedef struct _nty_coroutine_rbtree_wait nty_coroutine_rbtree_wait;
    4. typedef struct _nty_schedule {
    5. uint64_t birth; nty_cpu_ctx ctx;
    6. struct _nty_coroutine *curr_thread;
    7. int page_size;
    8. int poller_fd;
    9. int eventfd;
    10. struct epoll_event eventlist[NTY_CO_MAX_EVENTS];
    11. int nevents;
    12. int num_new_events;
    13. nty_coroutine_queue ready;
    14. nty_coroutine_rbtree_sleep sleeping;
    15. nty_coroutine_rbtree_wait waiting;
    16. } nty_schedule;

      调度器从3部分来得到就绪IO的协程:就绪集合、睡眠集合、等待集合,代码如下。

    1. void nty_schedule_run(void) {
    2. nty_schedule *sched = nty_coroutine_get_sched();
    3. if (sched == NULL) return ;
    4. while (!nty_schedule_isdone(sched)) {
    5. // 1. expired --> sleep rbtree 睡眠等待时间
    6. nty_coroutine *expired = NULL;
    7. while ((expired = nty_schedule_expired(sched)) != NULL) {
    8. nty_coroutine_resume(expired);//那些时间到期了,恢复协程的运行
    9. }
    10. // 2. ready queue 就绪队列
    11. nty_coroutine *last_co_ready = TAILQ_LAST(&sched->ready, _nty_coroutine_queue);
    12. while (!TAILQ_EMPTY(&sched->ready)) {
    13. //从就绪队列拿出第一个节点
    14. nty_coroutine *co = TAILQ_FIRST(&sched->ready);
    15. TAILQ_REMOVE(&co->sched->ready, co, ready_next);
    16. if (co->status & BIT(NTY_COROUTINE_STATUS_FDEOF)) {
    17. nty_coroutine_free(co);
    18. break;
    19. }
    20. nty_coroutine_resume(co);//恢复协程的运行
    21. if (co == last_co_ready) break;
    22. }
    23. // 3. wait rbtree IO等待 其他协程让出后,回到调度器这里
    24. //调度器处理IO等待
    25. nty_schedule_epoll(sched);//调用epoll_wait,监听就绪IO,sched->num_new_events就是IO事件数量
    26. while (sched->num_new_events) {
    27. int idx = --sched->num_new_events;
    28. struct epoll_event *ev = sched->eventlist+idx;
    29. int fd = ev->data.fd;
    30. int is_eof = ev->events & EPOLLHUP;
    31. if (is_eof) errno = ECONNRESET;
    32. nty_coroutine *co = nty_schedule_search_wait(fd);//通过fd,从红黑树中获取对应的coroutine
    33. if (co != NULL) {
    34. if (is_eof) {
    35. co->status |= BIT(NTY_COROUTINE_STATUS_FDEOF);
    36. }
    37. nty_coroutine_resume(co);//恢复,返回到协程
    38. 的运行
    39. }
    40. is_eof = 0;
    41. }
    42. }
    43. nty_schedule_free(sched);
    44. return ;
    45. }

      fd如何知道就绪?

      创建协程时,把fd添加到epoll进行管理,然后yied让出给调度器,由调度器resume到IO就绪的协程。其实调度器通过epoll_wait()监听IO是否就绪,得到就绪的fd,通过fd从红黑树中获取对应的协程,再通过resume()回到该就绪fd对应的协程,该协程继续执行accept/recv/send等阻塞API。

    六、协程的接口

      协程接口分为两部分

      (1)协程本身的API  

        创建协程:int nty_coroutine_create(nty_coroutine **new_co, proc_coroutine func, void *arg);     运行调度器:void nty_schedule_run(void);

      (2)posix的API

        对于需要等待IO就绪的的网络IO的操作函数需重新封装使用,而不是直接使用系统提供的,对于不需等待就绪的,可以不进行封装,需要的有:accept,connect,send,write,sendto,recv,read,recvfrom;不需要:socket,close,fcntl,setsocketopt,getsocketopt,listen。

    七、多核问题

    (1)多进程

      每一个进程亲和一个cpu,通过sched_setaffinity函数设置亲和力

    (2)多线程

      需要对调度器进行加锁

      sleep_rbtree中取出超时的节点,进行加锁mutex

      ready_queue中取出就绪的节点,进行springlock

      wait_rbtree中获取就绪IO可以使用mutex

    (3)x86指令,未实现。

    八、hook钩子

      这里为啥要介绍hook呢?因为使用协程时要把posix的API重新进行封装,所以可以使用hook劫持posix的API封装成自己的函数,hook钩子函数可以劫持两类函数:

       (1)系统的函数,使用dlsym();

       (2)第三方的库函数,使用dlopen();

      初始化hook后,就把系统的函数劫获,运行时执行的是自己定义的函数,而不是系统的函数(类似重定向),特别注意的是,其他的应用程序调用系统函数,不会执行当前应用程序对应定义hook函数,因为当前的应用程序调用系统函数时,只执行对应定义的函数,这个只限于当前应用程序。

      例子1:协程+mysql,不去修改mysql-dev,使用hook来重新定义connect、read、recv、send、write等函数;

      例子2:hook来劫持malloc和free检查内存泄露;

      例子3:nginx运行在dpdk也是使用hook的方法;

  • 相关阅读:
    TOP命令详解
    【概率论】第八章 假设检验
    2023 年最新Java 毕业设计选题题目参考,500道 Java 毕业设计题目,值得收藏
    leetcode每日一题第二十二天-剑指 Offer 63. 股票的最大利润/(重新整合一遍股票问题所有情况)
    Nature Communications | 张阳实验室:端到端深度学习实现高精度RNA结构预测
    聚观早报 | OPPO开发者大会月底召开;新iPad Pro将搭载M2芯片
    C++ Primer学习笔记-----第一章:开始
    [Vulnhub] Me and my girlfriend
    堡垒机部署
    从数据的crud开始讲起,回顾一下Buffer Pool在数据库里的地位
  • 原文地址:https://blog.csdn.net/Linuxhus/article/details/126905727