纯c协程框架NtyCo实现与原理

本文涉及的产品
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 RDS MySQL,高可用系列 2核4GB
简介: 纯c协程框架NtyCo实现与原理

前言

 c++两个比较好的协程库libgo和libco,本文用纯c的代码实现一个协程ntyco。


 ntyco源码地址——> gopherWxf/NtyCo——>源码里面有注释,本文是对ntyco的整体做一个梳理。


 如果了解过golang的协程的请注意,golang调度器的策略见→ 深入理解GMP模型 ,golang的协程与ntyco的协程不一样,ntyco的调度策略是基于 io事件 来做的。

  协程用在以下三个方面

1. 文件io的操作
2. 网络io的操作
3. mysql,redis等第三方库的操作

  本专栏知识点是通过零声教育的线上课学习,进行梳理总结写下文章,对c/c++linux课程感兴趣的读者,可以点击链接 C/C++后台高级服务器课程介绍 详细查看课程的服务。

1. 为什么会有协程,协程解决了什么问题?

网络IO优化

 在CS,BS的开发模式下,服务器的吞吐量是一个受关注的参数。吞吐量等于1秒内业务处理的次数,那么这个业务处理其实是由 网络IO时间 + 业务处理时间 组成的。


 不同的业务,其业务处理时间是不同的,所以对于业务处理时间的优化,要根据业务场景来优化。而网络IO时间是可以优化的。


 也就是说,如何提升recv和send的性能?现在以Linux服务器百万并发实现为例,使用epoll管理百万长连接,测试IO同步操作与异步操作的性能差别。


 对于响应式服务器来说,所有客户端的操作都是源于这个大循环,对于服务器处理网络IO,handle(sockfd) 的实现有两种方式。第一种,IO同步;第二种,IO异步。

IO 同步 操作性能测试

  对于IO 同步操作来说,handle(sockfd) 函数内部实现如下,我们发现IO操作(recv,send)与 epoll_wait 是在同一个处理流程里面的。也就是说,同步:检测IO 与 读写IO 在同一个流程里

Linux服务器百万并发实现就是以同步的方式做的,我们测试出来,每一千个连接,耗时7.5秒左右。

优点:

  1. sockfd 管理方便
  2. 代码逻辑清晰

缺点:

  1. 服务器程序依赖 epoll_wait 的循环,响应速度慢。
  2. 程序性能差

IO 异步 操作性能测试

  对于IO 异步操作来说,handle(sockfd) 函数内部将 sockfd 的操作,push 到线程池中 , 由其他线程进行读写,也就是说,异步:检测IO 与 读写IO 不在同一个流程里

  handle(sockfd) 函数将sockfd处理放到另一个线程中处理,使得IO操作(recv,send) 与 epoll_wait 不在一个处理流程里面,实现了解耦,这就叫做IO 异步操作。可以看到下图,每一千个连接耗时2.5秒左右。

优点:

  1. 子模块好规划
  2. 程序性能高

缺点:

  1. 正因为子模块好规划,使得模块之间的 sockfd 的管理异常麻烦。每一个子线程都需要管理好 sockfd,避免在 IO 操作的时候,sockfd 出现关闭或其他异常。通俗来讲,需要避免一个fd被多个线程操作的情况发生。

协程的诞生

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

 从上面我们知道了IO同步操作,写代码逻辑清晰,但是效率低;而IO异步操作,fd管理复杂,但是效率高。由此,协程便出现了。


 协程:把两者结合起来,以同步的编程方式,实现异步的性能。即写代码的时候,同步;运行的逻辑,异步。就如下图一样,代码是同步的,而我们通过hook,其运行的时候,就是异步的。

 可能有人会说了,上面IO异步操作性能测试明明是用了两个线程,当然比一个线程的同步IO快。首先明确一个概念:同步和异步,一定是描述两者之间的关系:


同步:检测IO 与 读写IO 在同一个流程里

异步:检测IO 与 读写IO 不在同一个流程里


 然后再来理解下面的QA问题,注意需要在理解本文协程的时候才能看懂,到时候回过头再看即可。


 Q:为什么协程的效率会更高?单线程reactor用的是epoll_wait, 协程调度也是epoll_wait,从整体上来看不是一样吗?


 A:在没有加入业务解析的情况下,协程性能与单线程reactor是差不多的 ,但是编程会容易很多。首先,用协程业务代码会比较简单,一个协程对应一个fd,业务逻辑都在协程内部;而reactor提供的recv_cb和send_cb是所有业务流程的。

 Q:有了业务解析,效率不也一样吗?


 A:业务部分,比如数据库操作的,是比较耗时的阻塞IO,而协程可以通过hook,把recv和send变成异步,把数据库IO阻塞的时间,切换到别的协程上运行,所有阻塞等待的地方,都会引起切换。而同步reactor在业务部分数据库IO就只能干等着。


 Q:为什么会有协程,协程解决了什么问题?


 A:IO同步操作,写代码逻辑清晰,但是效率低;而IO异步操作,fd管理复杂,但是效率高。协程解决了IO同步效率低,IO异步fd管理发杂的问题,协程结合两者的优点,实现了以同步的编程方式,实现异步的性能。


2. 原语

yield()

 先来理解yield的含义,让出,将当前的执行流程让出,让出给调度器。


 那么什么时候需要yield让出呢?很明显在recv之前,send之前,也就是在io之前,因为我们不知道io是否准备就绪了,所以我们先将fd加入epoll中,然后yield让出,将执行流程给调度器运行。


schedule

 schedule调度器做什么事情呢?调度器就是io检测,调度器就是不断的调用epoll_wait,来检测哪些fd准备就绪了,然后就恢复相应fd的执行流程执行现场。注意schedule不是原语,schedule是调度器。


resume()

 从上面我们得知恢复是被schedule恢复的,那么现在恢复到了原来流程的哪里呢?其实是恢复到了yield的下一条代码处。通常下面的代码都会将fd从epoll中移除,然后执行recv或send操作,因为一旦被resume,就说明肯定是准备就绪的。

代码+图 进行理解

 那么如何以同步的编程方式来实现异步的性能呢?我们是否可以包装一层recv(不包装用hook也可以,后面再写hook),在调用recv之前(假设现在的流程为A),将fd加入epoll中,然后将执行流程让出切换给一个调度器(假设调度器的流程为schedule),调度器里面执行epoll_wait,由调度器选择接下来恢复执行哪个流程(这个流程一定是就绪的,因为是从epoll_wait中返回的),可能会恢复到流程B中,那么流程B先将自己的事件从epoll中摘除EPOLL_CTL_DEL,然后就可以执行recv了。我们发现检测IO 与 读写IO 不在同一个流程里,实现了异步,而我们代码看起来却是同步的。


 我们从中发现两个原语操作,让出yield和恢复resume

如何实现yield和resume

  • yield:从io操作流程切换到调度器流程(让出)
  • resume:从调度器流程切换到io操作流程(恢复)

 到现在为止应该能理解yield和resume的意思了,但是对于初学者来说,肯定会有疑问,这个切换怎么实现?


如何实现yield和resume:


  1. setjmp/longjmp
  2. ucontext
  3. 用汇编代码自己实现切换

本文采用汇编代码实现切换_switch()。


  • yield=_switch(A,B)
  • resume=_switch(B,A)
//new_ctx[%rdi]:即将运行协程的上下文寄存器列表; cur_ctx[%rsi]:正在运行协程的上下文寄存器列表
int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);
// yield让出
void nty_coroutine_yield(nty_coroutine *co) {
    _switch(&co->sched->ctx, &co->ctx);
}
// resume协程恢复执行
int nty_coroutine_resume(nty_coroutine *co) {
  //...
    nty_schedule * sched = nty_coroutine_get_sched();
    sched->curr_thread = co;
    _switch(&co->ctx, &co->sched->ctx);
    //...
}

  如何从一个协程切换到另一个协程呢?我们只需要将当前协程的上下文从寄存器组中保存下来;将下一个要运行的协程的上下文放到寄存器组上去,即可实现协程的切换。

3. 切换

寄存器介绍

下面介绍的都是x86_64的寄存器。


  • %rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函数参数,依次对应第1参数,第2参数…(这里我们只需关注%rdi和%rsi)
  • %rbx,%rbp,%r12,%r13,%14,%15 用作数据存储,遵循被调用者使用规则,简单说就是随便用,调用子函数之前要备份它,以防他被修改
  • new_ctx是一个指针,指向一块内存,它现在存在%rid里面,同理cur_ctx存在%rsi里面
  • %rsp代表栈顶,%rbp代表栈底,%eip代表cpu下一条待取指令的地址(这也就是为什么resume之后会接着运行代码流程的原因)
//new_ctx[%rdi]:即将运行协程的上下文寄存器列表; cur_ctx[%rsi]:正在运行协程的上下文寄存器列表
int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);

汇编实现切换

//寄存器 cpu上下文
typedef struct _nty_cpu_ctx {
    void *rsp;//栈顶
    void *rbp;//栈底
    void *eip;//CPU通过EIP寄存器读取即将要执行的指令
    void *edi;
    void *esi;
    void *rbx;
    void *r1;
    void *r2;
    void *r3;
    void *r4;
    void *r5;
} nty_cpu_ctx;
//new_ctx[%rdi]:即将运行协程的上下文寄存器列表; cur_ctx[%rsi]:正在运行协程的上下文寄存器列表
int _switch(nty_cpu_ctx *new_ctx, nty_cpu_ctx *cur_ctx);
//默认x86_64
__asm__(
"   .text               \n"
"   .p2align 4,,15      \n"
".globl _switch                                          \n"
".globl __switch                                         \n"
"_switch:                                                \n"
"__switch:                                               \n"
"       movq %rsp, 0(%rsi)      # save stack_pointer     \n"
"       movq %rbp, 8(%rsi)      # save frame_pointer     \n"
"       movq (%rsp), %rax       # save insn_pointer      \n"
"       movq %rax, 16(%rsi)     # save eip               \n"
"       movq %rbx, 24(%rsi)     # save rbx,r12-r15       \n"
"       movq %r12, 32(%rsi)                              \n"
"       movq %r13, 40(%rsi)                              \n"
"       movq %r14, 48(%rsi)                              \n"
"       movq %r15, 56(%rsi)                              \n"
"       movq 56(%rdi), %r15                              \n"
"       movq 48(%rdi), %r14                              \n"
"       movq 40(%rdi), %r13                              \n"
"       movq 32(%rdi), %r12                              \n"
"       movq 24(%rdi), %rbx     # restore rbx,r12-r15    \n"
"       movq 8(%rdi), %rbp      # restore frame_pointer  \n"
"       movq 0(%rdi), %rsp      # restore stack_pointer  \n"
"       movq 16(%rdi), %rax     # restore insn_pointer   \n"
"       movq %rax, (%rsp)       # restore eip            \n"
"       ret                     # 出栈,回到栈指针,执行eip指向的指令。\n"
);

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

4. 协程的运行流程

协程如何使用,协程的api

  在网络IO编程的时候,如果每次accept返回的时候,为新来的fd单独分配一个线程,这一个fd对应一个线程,就不会存在多个线程共用一个fd的问题了,虽然这样代码逻辑清晰易读,但是这是无稽之谈,线程创建与线程调度的代价是很大的

  但是如果把线程换成协程,线程API的思维来使用协程,那不久可行了吗?

  NtyCo封装了两类接口

  • 一类是协程本身的api
//创建协程
int nty_coroutine_create(nty_coroutine **new_co, proc_coroutine func, void *arg);
//调度器运行
void nty_schedule_run(void);
  • 一类是posix api的异步封装协程api
//POSIX 异步封装 API
int nty_socket(int domain, int type, int protocol);
int nty_accept(int fd, struct sockaddr *addr, socklen_t *len);
ssize_t nty_recv(int fd, void *buf, size_t len, int flags);
ssize_t nty_send(int fd, const void *buf, size_t len, int flags);
int nty_close(int fd);
int nty_connect(int fd, struct sockaddr *name, socklen_t len);
ssize_t nty_recvfrom(int fd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
ssize_t nty_sendto(int fd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);

协程工作流程

创建协程

创建协程的时候,进行了如何的工作?

int nty_coroutine_create(nty_coroutine **new_co, proc_coroutine func, void *arg);
  • nty_coroutine **new_co :需要传入空的协程的对象,这个对象是由内部创建的,并且在函数返回的时候,会返回一个内部创建的协程对象。
  • proc_coroutine func :协程的子过程。当协程被调度的时候,就会执行该
  • 函数。
  • void *arg :需要传入到新协程子过程中的参数。

  协程不存在亲属关系,都是一致的调度关系,接受调度器的调度。调用 create API就会创建一个新协程,新协程就会加入到调度器的就绪队列中。

回调协程的子过程

  在 create 协程后,何时回调子过程?何种方式回调子过程?我们知道CPU的EIP寄存器就是存储cpu下一条指令的地址,我们可以把回调函数的地址存储到 EIP 中。这样在resume之后,就会执行协程的子过程了。

// eip 执行入口
static void _exec(void *lt) {
    nty_coroutine *co = (nty_coroutine *) lt;
    co->func(co->arg);
}
// 初始化协程栈
static void nty_coroutine_init(nty_coroutine *co) {
    void **stack = (void **) (co->stack + co->stack_size);
    stack[-3] = NULL;
    stack[-2] = (void *) co;//设置参数
    co->ctx.rsp = (void *) stack - (4 * sizeof(void *));
    co->ctx.rbp = (void *) stack - (3 * sizeof(void *));
    co->ctx.eip = (void *) _exec;//设置回调函数入口
    co->status = BIT(NTY_COROUTINE_STATUS_READY);
}

协程封装posix api异步原理

  在 send 与 recv 调用的时候,如何实现异步操作的?先来看一下一段代码:

 在进行 IO 操作(recv,send)之前,先执行了 epoll_ctl 的 del 操作,将相应的 sockfd 从 epfd中删除掉,在执行完 IO 操作(recv,send)再进行 epoll_ctl 的 add 的动作。这段代码看起来似乎好像没有什么作用。


 如果是在多个上下文中,这样的做法就很有意义了。能够保证 sockfd 只在一个上下文中能够操作 IO 的。不会出现在多个上下文同时对一个 IO 进行操作的。协程的 IO 异步操作正式是采用此模式进行的。

  详细说明见目录中2.原语->代码+图 进行理解

// 创建协程recv接口
ssize_t nty_recv(int fd, void *buf, size_t len, int flags) {
    struct epoll_event ev;
    ev.events = POLLIN | POLLERR | POLLHUP;
    ev.data.fd = fd;
    //加入epoll,然后yield
    nty_epoll_inner(&ev, 1, 1);
    //resume
    ssize_t ret = recv(fd, buf, len, flags);
    return ret;
}
// 加入epoll,更改状态,加入wait集合,然后yield与resume
static int nty_epoll_inner(struct epoll_event *ev, int ev_num, int timeout) {
    nty_schedule * sched = nty_coroutine_get_sched();
    nty_coroutine *co = sched->curr_thread;
    int i;
    for (i = 0; i < ev_num; i++) {
        epoll_ctl(sched->epfd, EPOLL_CTL_ADD, ev->data.fd, ev);
        co->events = ev->events;
        //加入wait集合,添加wait状态
        nty_schedule_sched_wait(co, ev->data.fd, ev->events, timeout);
    }
    //yield
    nty_coroutine_yield(co);
    for (i = 0; i < ev_num; i++) {
        epoll_ctl(sched->epfd, EPOLL_CTL_DEL, ev->data.fd, ev);
        //移除wait集合,移除wait状态
        nty_schedule_desched_wait(ev->data.fd);
    }
    return ev_num;
}

一个简单的使用案例

可以看到,我们编写代码只需以同步的编程方式,就能实现异步的性能了。

#include "nty_coroutine.h"
#include <arpa/inet.h>
void server_reader(void *arg) {
    int fd = *(int *) arg;
    ssize_t ret;
    struct pollfd fds;
    fds.fd = fd;
    fds.events = POLLIN;
    while (1) {
        char buf[1024] = {0};
        ret = nty_recv(fd, buf, 1024, 0);
        if (ret > 0) {
            nty_send(fd, buf, strlen(buf), 0);
        }
        else if (ret == 0) {
            nty_close(fd);
            break;
        }
    }
}
void server(void *arg) {
    unsigned short port = *(unsigned short *) arg;
    int fd = nty_socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) return;
    struct sockaddr_in local, remote;
    local.sin_family = AF_INET;
    local.sin_port = htons(port);
    local.sin_addr.s_addr = INADDR_ANY;
    bind(fd, (struct sockaddr *) &local, sizeof(struct sockaddr_in));
    listen(fd, 128);
    while (1) {
        socklen_t len = sizeof(struct sockaddr_in);
        int cli_fd = nty_accept(fd, (struct sockaddr *) &remote, &len);
        printf("new client comming\n");
        nty_coroutine *read_co;
        nty_coroutine_create(&read_co, server_reader, &cli_fd);
    }
}
int main(int argc, char *argv[]) {
    nty_coroutine *co = NULL;
    unsigned short port = 8080;
    nty_coroutine_create(&co, server, &port);
    nty_schedule_run(); //run
    return 0;
}

5. 协程 与 调度器 结构体定义

协程定义

 一个协程会有哪些状态呢?如果协程sleep了,那么就是睡眠状态,如果协程刚创建出来,那它肯定是就绪状态,如果协程在等待数据的到来,那就是等待状态。这里这里定义协程的三个运行状态{就绪,睡眠,等待}。


  • 新创建的协程,加入就绪集合等待调度
  • io未就绪的协程,加入等待集合等待epoll_wait
  • 有sleep操作的协程,加入睡眠集合
  • 就绪集合没有设置优先级,所以在就绪集合里面的协程优先级一样,那么就可以用队列来存储,先进先出
  • 等待集合就是等待IO准备就绪,这个等待IO是有时间长短的,这里用红黑树来存储
  • 睡眠集合需要按照睡眠时间的长短进行唤醒,所以也用红黑树存储,key为睡眠时长


 我们描述了每一个协程有自己的上下文环境,需要保存 CPU 的寄存器 ctx;需要有子过程的回调函数 func;需要有子过程回调函数的参数 arg;需要定义自己的栈空stack;需要有自己栈空间的大小 stack_size;需要定义协程的创建时间birth;需要定义协程当前的运行状态 status;需要定当前运行状态的结点(ready_next, wait_node, sleep_node);需要定义协程 id;需要定义调度器的全局对象 sched。

typedef struct _nty_coroutine {
    //cpu ctx
    nty_cpu_ctx ctx;
    // func
    proc_coroutine func;
    void *arg;
    // create time
    uint64_t birth;
    //stack
    void *stack;
    size_t stack_size;
    size_t last_stack_size;
    //status
    nty_coroutine_status status;
    //root
    nty_schedule *sched;
    //co id
    uint64_t id;
    //fd event
    int fd;
    uint16_t events;
    //sleep time
    uint64_t sleep_usecs;
    //set
    RB_ENTRY(_nty_coroutine) sleep_node;
    RB_ENTRY(_nty_coroutine) wait_node;
    TAILQ_ENTRY(_nty_coroutine) ready_node;
} nty_coroutine;

调度器定义

 每个协程所需要使用的,而且不同的,就是协程的属性,那么每个协程所需要的,且相同的,就是调度器的属性。用来管理所有协程的属性,作为调度器的属性。调度器的属性,需要有保存 CPU 的寄存器上下文 ctx,可以从协程运行状态yield 到调度器运行的。从协程到调度器用 yield,从调度器到协程用 resume。

typedef struct _nty_schedule {
    // create time
    uint64_t birth;
    //cpu ctx
    nty_cpu_ctx ctx;
    //stack_size
    size_t stack_size;
    //coroutine num
    int spawned_coroutines;
    //default_timeout
    uint64_t default_timeout;
    //当前调度的协程
    struct _nty_coroutine *curr_thread;
    //页大小
    int page_size;
    //epoll fd
    int epfd;
    //线程通知相关,暂未实现
    int eventfd;
    //events
    struct epoll_event eventlist[NTY_CO_MAX_EVENTS];
    int num_new_events;
    //set
    nty_coroutine_queue ready;
    nty_coroutine_rbtree_sleep sleeping;
    nty_coroutine_rbtree_wait waiting;
} nty_schedule;

6. 调度的策略

  调度器的实现,有两种方案,一种是生产者消费者模式,另一种多状态运行。

生产者消费者模式

逻辑代码如下

多状态运行

逻辑代码如下

7. 协程api 与 hook

需要封装为异步的posix api分析

  所有对io的操作,我们都需要取重新封装一遍。为什么不能用posix api,而是我们需要再去封装一次呢?比如我们调用recv的时候,如果我们调用系统的,那么这个fd怎么yield到调度器上呢,所以我们需要在posix api的基础上封装,当然有些接口需要封装,有些不需要。

就像下面的伪代码一样,从同步的recv变成异步的ney_recv

//伪代码
ney_recv(){
  epoll add fd;
  yield();
  epoll del fd;
  recv(fd);
}

  站在同步封装成异步的角度,如果不需要判断io是否就绪的这些api,则不需要封装为异步的。

需要封装的api,这些api在实现的时候,皆采用上面伪代码的策略

1. accept()
2. connect()
3. recv()
4. read()
5. send()
6. write()
7. recvfrom()
8. sendto()

不需要封装的api,这些api因为不会引起阻塞,所以不用封装。

socket()
listen()
close()
fcntl()
setsockopt()
getsockopt()

hook

 我们有两种策略封装上面的api,第一种就是定义Nty_XXX(),框架独立定义一套标准接口出来。但是这种方法,如果跟mysql,redis建立连接,但是不去修改它们提供的客户端源码开发包的时候,就会发现连不上去,因为其源码用的是posix api,recv和send。而协程用的是nty_recv()和nty_send()。两者之间没有关联。


 第二种就是使用hook,做成跟系统调用,跟posix api一样的接口,那么一样的接口就会引起冲突。这个冲突我们就使用hook来解决。


 hook提供了两个接口;1. dlsym()是针对系统的,系统原始的api。2. dlopen()是针对第三方的库。


 我们现在使用mysql的包去连接mysql来做hook演示,可以看到我们将系统调用的api截获了。对应下面的代码,就是这个意思。


 原来的系统调用,被hook后,都变成了xxx_f , 所以我们就可以使用原来的名字,比如read,connect。而mysql包里面走的read,connect这些函数,就变成我们写的函数了,如果我们协程提供的api使用hook的做法,那么mysql,redis这类静态库,动态库的read,write都不用修改,使用了hook,就都会走我们定义的函数。


 简单来说,原来的系统调用connect,被改名为connect_f , 所以原来的名字connect就空出来了,交由我们用户实现。而像mysql-dev源码里面用的都是connect,所以就会走我们用户写的函数了。

connect_f = dlsym(RTLD_NEXT, "connect");

#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <dlfcn.h>
#include<mysql/mysql.h>
//
// Created by 68725 on 2022/7/17.
//
typedef int (*connect_t)(int, struct sockaddr *, socklen_t);
connect_t connect_f;
typedef ssize_t (*recv_t)(int, void *buf, size_t, int);
recv_t recv_f;
typedef ssize_t (*send_t)(int, const void *buf, size_t, int);
send_t send_f;
typedef ssize_t (*read_t)(int, void *buf, size_t);
read_t read_f;
typedef ssize_t (*write_t)(int, const void *buf, size_t);
write_t write_f;
int connect(int fd, struct sockaddr *name, socklen_t len) {
    printf("in connect\n");
    return connect_f(fd, name, len);
}
ssize_t recv(int fd, void *buf, size_t len, int flags) {
    printf("in recv\n");
    return recv_f(fd, buf, len, flags);
}
ssize_t send(int fd, const void *buf, size_t len, int flags) {
    printf("in send\n");
    return send_f(fd, buf, len, flags);
}
ssize_t read(int fd, void *buf, size_t len) {
    printf("in read\n");
    return read_f(fd, buf, len);
}
ssize_t write(int fd, const void *buf, size_t len) {
    printf("in write\n");
    return write_f(fd, buf, len);
}
static int init_hook() {
    connect_f = dlsym(RTLD_NEXT, "connect");
    recv_f = dlsym(RTLD_NEXT, "recv");
    send_f = dlsym(RTLD_NEXT, "send");
    read_f = dlsym(RTLD_NEXT, "read");
    write_f = dlsym(RTLD_NEXT, "write");
}
void main() {
    init_hook();
    MYSQL *m_mysql = mysql_init(NULL);
    if (!m_mysql) {
        printf("mysql_init failed\n");
        return;
    }
    if (!mysql_real_connect(m_mysql, "192.168.109.1", "root", "123456", "cdb", 3306, NULL, 0)) {
        printf("mysql_real_connect failed\n");
        return;
    }
    else {
        printf("mysql_real_connect success\n");
    }
}
//gcc -o hook hook.c -lmysqlclient -I /usr/include/mysql/ -ldl

  如果我们的协程ntyco要和mysql做在一起,我们在不修改mysql-dev的前提下,只要把下面的接口做成hook即可

1. accept()
2. connect()
3. recv()
4. read()
5. send()
6. write()
7. recvfrom()
8. sendto()

8. 多核模式

解决协程多核的问题有两种方式,多线程/多进程 与 CPU核心做亲和性。


  1. 多进程(实现起来容易,对协程代码本身不用去改)
  2. 多线程(复杂,需要对调度器进行加锁)

那么做多线程对调度器进行加锁,锁放在哪呢?锁放在调度器结构体里面,因为调度器是全局唯一的,那么要锁哪里呢,很明显,<取协程,恢复协程>,这里需要加锁。

9. 协程性能测试

4 台 VMWare 虚拟机


1 台服务器 14G 内存,1 核 CPU

3 台客户端 4G 内存,1 核 CPU


操作系统:ubuntu 18.04.6


服务端测试代码: 服务器端测试代码


客户端测试代码:客户端测试代码


按照每一个连接启动一个协程来测试,每个协程栈4k,测试协程数量100w无异常,并且能够正常收发数据。

相关实践学习
每个IT人都想学的“Web应用上云经典架构”实战
本实验从Web应用上云这个最基本的、最普遍的需求出发,帮助IT从业者们通过“阿里云Web应用上云解决方案”,了解一个企业级Web应用上云的常见架构,了解如何构建一个高可用、可扩展的企业级应用架构。
高可用应用架构
欢迎来到“高可用应用架构”课程,本课程是“弹性计算Clouder系列认证“中的阶段四课程。本课程重点向您阐述了云服务器ECS的高可用部署方案,包含了弹性公网IP和负载均衡的概念及操作,通过本课程的学习您将了解在平时工作中,如何利用负载均衡和多台云服务器组建高可用应用架构,并通过弹性公网IP的方式对外提供稳定的互联网接入,使得您的网站更加稳定的同时可以接受更多人访问,掌握在阿里云上构建企业级大流量网站场景的方法。 学习完本课程后,您将能够: 理解高可用架构的含义并掌握基本实现方法 理解弹性公网IP的概念、功能以及应用场景 理解负载均衡的概念、功能以及应用场景 掌握网站高并发时如何处理的基本思路 完成多台Web服务器的负载均衡,从而实现高可用、高并发流量架构
目录
相关文章
|
6月前
|
API 调度
2.3.1 协程设计原理与汇编实现
2.3.1 协程设计原理与汇编实现
|
3月前
|
存储 Linux 调度
协程(coroutine)的原理和使用
协程(coroutine)的原理和使用
|
20天前
|
存储 安全 测试技术
GoLang协程Goroutiney原理与GMP模型详解
本文详细介绍了Go语言中的Goroutine及其背后的GMP模型。Goroutine是Go语言中的一种轻量级线程,由Go运行时管理,支持高效的并发编程。文章讲解了Goroutine的创建、调度、上下文切换和栈管理等核心机制,并通过示例代码展示了如何使用Goroutine。GMP模型(Goroutine、Processor、Machine)是Go运行时调度Goroutine的基础,通过合理的调度策略,实现了高并发和高性能的程序执行。
77 29
|
17天前
|
负载均衡 算法 Go
GoLang协程Goroutiney原理与GMP模型详解
【11月更文挑战第4天】Goroutine 是 Go 语言中的轻量级线程,由 Go 运行时管理,创建和销毁开销小,适合高并发场景。其调度采用非抢占式和协作式多任务处理结合的方式。GMP 模型包括 G(Goroutine)、M(系统线程)和 P(逻辑处理器),通过工作窃取算法实现负载均衡,确保高效利用系统资源。
|
2月前
|
调度 Python
揭秘Python并发编程核心:深入理解协程与异步函数的工作原理
在Python异步编程领域,协程与异步函数成为处理并发任务的关键工具。协程(微线程)比操作系统线程更轻量级,通过`async def`定义并在遇到`await`表达式时暂停执行。异步函数利用`await`实现任务间的切换。事件循环作为异步编程的核心,负责调度任务;`asyncio`库提供了事件循环的管理。Future对象则优雅地处理异步结果。掌握这些概念,可使代码更高效、简洁且易于维护。
26 1
|
1月前
|
数据采集 调度 Python
Python编程异步爬虫——协程的基本原理(一)
Python编程异步爬虫——协程的基本原理(一)
|
1月前
|
数据采集 Python
Python编程异步爬虫——协程的基本原理(二)
Python编程异步爬虫——协程的基本原理(二)
|
1月前
|
存储 前端开发 rax
协程设计与原理(二)
协程设计与原理(二)
17 0
|
1月前
|
Java Linux Go
协程的设计原理(一)
协程的设计原理(一)
32 0
|
4月前
|
调度 Python
揭秘Python并发编程核心:深入理解协程与异步函数的工作原理
【7月更文挑战第15天】Python异步编程借助协程和async/await提升并发性能,减少资源消耗。协程(async def)轻量级、用户态,便于控制。事件循环,如`asyncio.get_event_loop()`,调度任务执行。异步函数内的await关键词用于协程间切换。回调和Future对象简化异步结果处理。理解这些概念能写出高效、易维护的异步代码。
58 2