一、协程的起源
- 为什么会有协程?
对于响应式服务器,所有的客户端的操作驱动都是来源于这个大循环。来源于
epoll_wait 的反馈结果。
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);
}
}
}
对于服务器处理百万计的 IO。Handle(sockfd)实现方式有两种。
Handle方式一 ( IO同步操作)
handle(sockfd)函数内部对 sockfd 进行读写动作。代码如下
int handle(int sockfd) {
recv(sockfd, rbuffer, length, 0);
parser_proto(rbuffer, length);
send(sockfd, sbuffer, length, 0);
}
handle 的 io 操作(send,recv)与 epoll_wait 是在同一个处理流程里面的。这就是 IO 同步操作。
优点:
1. sockfd 管理方便。
2. 操作逻辑清晰。
缺点:
1. 服务器程序依赖 epoll_wait 的循环响应速度慢。
2. 程序性能差
Handle方式二 (IO异步线程池操作)
handle(sockfd)函数内部将 sockfd 的操作,push 到线程池中,代码如下:
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 实现解耦。这就叫做 IO 异步操作。
优点:
1. 子模块好规划。
2. 程序性能高。
缺点:
正因为子模块好规划,使得模块之间的 sockfd 的管理异常麻烦。每一个子线程都需要管理好 sockfd,避免在 IO 操作的时候,sockfd 出现关闭或其他异常。
2. 协程解决了什么问题?
实验证明,IO 同步操作,程序响应慢,IO 异步操作,程序响应快。
IO 异步操作与 IO 同步操作对比:
对比项 | IO同步操作 | IO异步操作 |
---|---|---|
Socket管理 | 管理方便 | 多个线程共同管理 |
代码逻辑 | 程序整体逻辑清晰 | 子模块逻辑清晰 |
程序性能 | 响应时间长,性能差 | 响应时间短,性能好 |
有没有一种方式,有异步性能,同步的代码逻辑。来方便编程人员对 IO 操作的组件呢? 有,采用一种轻量级的协程来实现。在每次 send 或者 recv 之前进行切换,再由调度器来处理 epoll_wait 的流程。
总结
协程解决的问题是:通过异步IO的方式,实现看起来是同步IO的代码逻辑。
3. 协程如何使用?与线程使用有何区别?
在做网络 IO 编程的时候,有一个非常理想的情况,就是每次 accept 返回的时候,就为新来的客户端分配一个线程,这样一个客户端对应一个线程。就不会有多个线程共用一个sockfd。每请求每线程的方式,并且代码逻辑非常易读。但是这只是理想,线程创建代价,但是调度代价就非常大了。
先来看一下每请求每线程的代码如下:
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);
}
如果我们有协程,我们就可以这样实现。参考代码如下:
while (1) {
socklen_t len = sizeof(struct sockaddr_in);
int cli_fd = nty_accept(fd, (struct sockaddr*)&remote, &len);
nty_coroutine *read_co;
nty_coroutine_create(&read_co, server_reader, &cli_fd);
}
线程的 API 思维来使用协程,函数调用的性能来测试协程。
4.协程可以用在什么地方?
协程可以使用的地方如:
- 文件操作
- mysql的操作
- 网络io
- redis的操作
这些需要等待IO完成耗时的地方,通过协程变成异步非阻塞IO,在等待IO事件完成时,把协程让出给协程调度器,由协程调度器决定调度某个已经完成IO的协程执行。
这样的好处是:
- 代码看起来是同步阻塞IO的逻辑,比较好理解,但实际上是异步非阻塞IO。(注:如果操作没有IO,那么用协程的意义不大。)
- 切换协程执行比内核切换线程的代价小得多。一个线程运行期间,其中某个协程阻塞了,马上切换另一个协程执行(由协程调度器决定切换到哪一个协程执行),切换代价很小。如果是直接调用系统阻塞API,那么当前线程就会阻塞,内核切换线程,切换代价很大。因此协程有必要对系统阻塞API进行一层封装。
所有可能引起阻塞的系统API,都可以通过协程调度器封装一层API,使得本来是同步阻塞的IO操作,通过我们封装好的协程调度器API把它转变成异步非阻塞IO的操作。
不需要封装的API(不可能导致阻塞)如:socket、close、fcntl、setsockopt、getsockopt、listen等。
需要封装的API(可能导致阻塞)如:系统API中的read、write、connect、accept、send、sendto、recv、recvfrom、sleep等。
connect 方法在建立连接时,会阻塞直到连接成功建立或者发生错误。
accept 方法在等待客户端连接时,会阻塞直到有客户端连接请求到达或者出现错误。
send/sendto 方法在发送数据时,会阻塞等待直到数据成功发送完毕或者发生错误。
recv/recvfrom 方法在接收数据时,会阻塞等待直到有数据到达或者发生错误。
read/write 方法在读写数据时,如果没有数据可读或者缓冲区已满,会阻塞等待直到有数据可读或者缓冲区有空闲空间。
封装API示例:
nty_recv(){
//添加到epoll管理。原理就是利用epoll管理所有的IO
epoll_ctl(add)
//直接让出CPU
yield();
//执行下面代码说明已经被epoll捕捉到sockfd有数据了,并且协程调度器主动resume了当前协程
//首先移除epoll管理
epoll_ctl(del)
//可以直接调用系统API读数据了,因为一定有数据可以读,不会出现阻塞或者无数据可读情况
recv(sockfd);
}
如果想要封装系统API中的read、write、connect、accept、send、sendto、recv、recvfrom、sleep等,成为自己的同名API,可参考如下代码:
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <fcntl.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <arpa/inet.h>
#include <mysql.h>
//原生系统API
typedef int(*connect_t)(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
connect_t connect_f;
typedef int(*accept_t)(int sockfd, struct sockaddr *addr