协程调度器实现与性能测试

主要通过以下10个方面来了解协程的原理:

  1. 为什么会有协程,协程解决什么问题?
  2. 协程的原语
  3. 协程的切换
  4. 协程的运行流程
  5. 协程的结构体定义
  6. 协程调度的策略
  7. 协程调度器如何定义
  8. 协程api的实现,hook
  9. 协程的多核模式
  10. 协程如何测试

1-4在《协程的设计与汇编实现》中已经介绍过。本文主要介绍6-10

协程结构体定义

struct coroutine {

    struct cpu_register_set *set; // 保存CPU寄存器组

    void *func; // coroutine_entry, 协程入口函数
    void *arg;

    void *retval; // 协程返回值,协程如果不做计算相关的,可以不要返回值;做io可以不需要

    void *stack_addr; // 协程栈,函数调用的作用;存储一对大括号内的临时变量;调用一次压栈,return出栈
    size_t stack_size; 
    // 共享栈 or 独立栈
    // 共享栈,也叫全局栈,所有协程共用一个栈。隔离性比较差,不推荐。实现很麻烦
    // 独立栈,每个协程分配独立空间

    //struct coroutine *next;

    queue_node(ready_queue, coroutine) *ready;
    rbtree_node(coroutine) *wait; // 等待io操作
    rbtree_node(coroutine) *sleep; // optional
};

struct cpu_register_set *set;,用来保存CPU寄存器组;

func和arg,表示协程的入口函数和参数。

协程创建与线程类似,coroutine_create(entry_cb, arg);

线程创建,pthread_create(&thid, NULL, entry_cb, arg);; 在内核里面创建一个线程实体,并把它加入到就绪队列;entry_cb的调用,跟pthread_create没有关系。它的调用,是因为调度器抓取了一个线程,开始运行。

void *retval;,协程返回值,协程如果不做计算相关的,可以不要返回值;**做io可以不需要,**我们实现的时候,没有实现返回值。返回值可以参考线程的方式。

线程的返回值通过pthread_join(pthread_t thread, void **retval)获取到,线程的返回值存在这里,等待子线程退出,或者子线程返回的值。

父协程如果如果要获取子协程的返回值,可以用类似的方式:

// 父协程中进行join
coroutine_join(coid, &ret) {

    co = search(coid)
    while (co->ret == NULL) {
        wait(); //cond_wait(); // 不能用线程的等待
    }

    return co->ret;
}

// 在子协程结束时进行signal,注意:也不是线程的signal
exec(co) {
    
    co->reval = co->func(co->arg);
    signal();
}

stack_add, 协程栈空间的首地址,stack_size, 栈空间大小;协程栈,函数调用的作用;存储一对大括号内的临时变量;调用一次压栈,return出栈。

协程栈有两种实现方式

  1. 共享栈;
  2. 独立栈

共享栈,也叫全局栈,所有协程共用一个栈。隔离性比较差,不推荐。实现很麻烦;

独立栈,每个协程分配独立空间,我们实现的是这种。

协程有几种状态?

就绪,等待,睡眠。

新创建的协程,或者可以进行io操作,状态设置为就绪;就绪集合使用队列,如果考虑协程运行的优先级,可以加上优先级;

等待,等待io操作;等待集合使用rbtree管理;

睡眠,表示协程休眠一定时间;睡眠集合使用rbtree进行管理;用timestamp做key。

睡眠为什么选择红黑树, 而不是小顶堆?

红黑树遍历是顺序的; 而小顶堆是无序的,同时取出多个节点的时候比较麻烦。小顶堆适合每次取一个。

对于io密集型,休眠不是很有用。实现它只是为了协程功能的完善性。

img

协程调度的策略

就绪、等待、睡眠如何配合使用?

  1. sleep

  2. new --> ready

  3. wait

我们实现的是生产者消费者的模式

img

逻辑代码如下:

while (1) {

    //遍历睡眠集合,将满足条件的加入到ready
    coroutine *expired = NULL;
    while ((expired = sleep_tree_expired(sched)) != ) {
        TAILQ_ADD(&sched->ready, expired);
    }
    
    //遍历等待集合,将满足添加的加入到ready
    coroutine *wait = NULL;
    int nready = epoll_wait(sched->epfd, events, EVENT_MAX, 1);
    for (i = 0;i < nready;i ++) {
        wait = wait_tree_search(events[i].data.fd);
        TAILQ_ADD(&sched->ready, wait);
    }
    
    // 使用resume回复ready的协程运行权
    while (!TAILQ_EMPTY(&sched->ready)) {
        coroutine *ready = TAILQ_POP(sched->ready);
        resume(ready);
    }

}

其他更为复杂的调度策略的设计,可以参考cfs

如果有多种调度策略同时存在,怎么做?

可以实现多种策略,提供给调度器进行选择。

调度器如何定义

struct scheduler {

    struct scheduler_ops *ops; // 调度策略
    struct coroutine *cur; // 当前运行的协程

    int epfd; //

    queue_node *ready_set;
    rbtree() *wait_set;
    rbtree() *sleep_set;
};

// 
struct scheduler_ops { // 调度策略

    struct scheduler_ops *next;

    enset();
    deset();

}; 

如果epoll_event的数组(CO_MAX_EVENTS,也就是epoll_wait从就绪队列里面带出来的最大io数量)只设置为1024,但是有大量io就绪,比如1500个,epoll_wait本可以带出来更多io,这个有什么方法解决?

epoll_wait一次只能带出来1024个io,剩下的需要等下次epoll_wait处理,就有可能造成有些那些io处理没有那么及时。有没有一种方法,能更加优先的处理io就绪的一次性全部处理完?没有找到合适的方法。

协程api的实现

协程的主要目的,就是要将同步posix api改为异步的。

同步改异步

协程api主要针对于io操作, 系统提供的posix api,都是同步的函数,返回成功或者失败,不返回没办法往下面走。

socket
bind
listen
accept
send
recv
close
connect

协程需要封装一组与posix相同的api,改成异步的。

以recv为例,通过下面方式将同步改为异步:

co_recv() {
    int ret = poll(fd) // 非阻塞poll
    if (ret > 0) {
        recv();
    } else {
        epoll_ctl(epfd,);
        yield();
    }
}

以recv为例,首先用poll检测io是否就绪,如果没有就绪,则将fd加入epoll,并将协程加入到等待集合,之后进行yield,将执行权交给调度器。调度器会选择协程进行调度。

其他api同步改为异步,也是类似的。

posix api封装原则,如果调用前不需要判断io是否准备就绪,也就是不会阻塞的,就不需要重新封装。

hook

协程实现了同步api改异步api后,应用程序怎么使用呢?比如mysql或者redis客户端中,怎么使用协程的api呢?

使用hook,可以直接使用协程的网络api替换系统调用。关于hook的使用,参考《手写内存泄漏组件》或者《手写死锁检测组件》, 那里面使用了hook的方法。

hook的原理,是使用了dlsym, 进程启动的时候,执行init_hook的时候,就将我们自己代码段的co_socket, co_accept等挂到系统的posix api,系统的socket, accept等posix api进行重定向。之后应用程序中,包括调用的动态库、静态库中,系统的posix api就会被我们实现的协程api接管。

int init_hook(void) {

	socket_f = (socket_t)dlsym(RTLD_NEXT, "socket");
	
	//read_f = (read_t)dlsym(RTLD_NEXT, "read");
	recv_f = (recv_t)dlsym(RTLD_NEXT, "recv");
	recvfrom_f = (recvfrom_t)dlsym(RTLD_NEXT, "recvfrom");

	//write_f = (write_t)dlsym(RTLD_NEXT, "write");
	send_f = (send_t)dlsym(RTLD_NEXT, "send");
    sendto_f = (sendto_t)dlsym(RTLD_NEXT, "sendto");

	accept_f = (accept_t)dlsym(RTLD_NEXT, "accept");
	close_f = (close_t)dlsym(RTLD_NEXT, "close");
	connect_f = (connect_t)dlsym(RTLD_NEXT, "connect");

}

协程的多核模式

协程多核方案,结合CPU亲缘性

  1. 多线程
  2. 多进程

多线程,如果每个线程一个调度器,就和多进程一样;如果多个线程共用一个调度器,就避免不了要加锁;每次取出ready、wait、sleep节点的时候加锁,返回的时候解锁。

多进程,如果是纯io操作,使用协程的话,使用多进程方案;每个进程一个调度器,互相之间不影响,这个是可以的。

如何测试

可以进行接入量和接入速度测试,与reactor进行对比;

协程方案接入量也可以做到100万以上;

接入速度对比, 协程与reactor速度差不多,协程能稍慢一点点。客户端连接到服务器,发送一条message;服务器接收到message,回发给客户端。同样的环境,分别使用reactor和协程的服务器进行测试,接入1000个客户端,reactor大约需要2200ms,协程大约需要2400ms。

协程速度比同步快,编程方式简单,性能接近异步,但不会超过异步。

总结

3种网络框架的比较:

协程为了更简单的进行io操作。解决io等待挂起的问题,提高了CPU的利用率。如果没有io操作,使用协程框架意义不大。

使用epoll + 线程池

epoll检测到有io数据,push到线程池中,其他线程进行io操作,实现异步操作。
会有不同的线程对同一个fd进行操作,肯定需要对fd加锁,这样的代码比较复杂。

使用协程

协程会提供一组api,accept,recv,send等,在实现业务层代码的时候,可以直接recv后send,看起来像是同步的代码,其实底层协程为我们实现了异步操作。每个fd一个协程。协程是轻量级的线程,是用户态的线程,有自己独立的空间。通过jump->back这种跳转的方式,实现协程之间的调度。
协程调度器是通过epoll_wait进行驱动的,协程进行yield,scheduler进行resume。

reactor

将对fd的管理转化为对event进行管理,当有event的时候,调用callback进行处理。广义上的异步,本质上是同步。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值