网络IO与Rector模型

89 阅读5分钟

IO检测与处理

IO函数

连接的建立

需要服务端和客户端分别建立socket套接字,客户端发送connect请求,服务端监听该请求,并accept该请求:

// 客户端
int connectfd = socket(AF_INET, SOCK_STREAM, 0);
int ret = connect(connectfd, (struct sockaddr *)&addr, sizeof(addr));

// 服务器
// accept负责从全连接队列获取fd,并操作IO
int clientfd = accept(listenfd, addr, sz);

连接的断开

TCP是全双工的,一个socket连接,会维护一个读缓冲区和一个写缓冲区

20230110194216

因此,连接的关闭分为主动和被动断开两种:

主动关闭:

close(fd);
shutdown(fd, SHUT_RDWR);
// 主动关闭本地读端,对端写段关闭
shutdown(fd, SHUT_RD);
// 主动关闭本地写端,对端读段关闭
shutdown(fd, SHUT_WR);

被动关闭:客户端关闭读或写端,服务器关闭写或读端(半关闭),也可能是读写端全关闭:

// 被动:读端关闭
// 有的网络编程需要支持半关闭状态 
int n = read(fd, buf, sz);
if (n == 0) {
close_read(fd);
// write()
// close(fd);
}
// 被动:写端关闭
int n = write(fd, buf, sz);
if (n == -1 && errno == EPIPE) {
close_write(fd);
// close(fd);
}

消息的处理

从读缓冲区读取数据:

int n = read(fd, buf, sz);
if (n < 0)
{   
    // n == -1:
    // 如果是:被其他信号打断,或buff为空,下次再继续读
    if (errno == EINTR || errno == EWOULDBLOCK)
        break;  
    // 其他的错误,关闭连接    
    close(fd);
}
// 客户端关闭客户端,发送FIN信号到服务器
else if (n == 0)  
{
    close(fd);
}
else
{
    // 处理 buf
}

往写缓冲区中写数据:

int n = write(fd, buf, dz);
if (n == -1)
{
    if (errno == EINTR || errno == EWOULDBLOCK)
    {
        return;
    }
    close(fd);
}

网络IO职责

检测IO

io 函数本身可以检测 io 的状态;但是只能检测一个 fd 对应的状态;io 多路复用可以同时检测多个io的状态;

区别是:io函数可以检测具体状态;io 多路复用只能检测出可读、可写、错误、断开等笼统的事件;

操作IO

IO函数操作IO,分为两种:阻塞和非阻塞IO,阻塞在网络线程,连接的fd阻塞属性决定了IO函数是否阻塞,其具体差异在:io 函数在数据未到达时是否立刻返回,也就是数据准备阶段是否会阻塞。

20230110202018

默认情况下,fd 是阻塞的,设置非阻塞的方法如下;

int flag = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flag | O_NONBLOCK);

IO多路复用

io 多路复用只负责检测io,不负责操作io,如epoll:

int n = epoll_wait(epfd, evs, sz, timeout);
timeout = -1 一直阻塞直到网络事件到达;
imeout = 0 不管是否有事件就绪立刻返回;
timeout = 1000 最多等待 1 s,如果1 s内没有事件触发则返回;

epoll在数据准备阶段也是阻塞,也就是epoll_wait会产生阻塞,如果epoll的就绪链表有数据,则说明数据准备好了进行返回,否则就会阻塞,timeout就是阻塞等待的事件。

20230110202303

epoll结构和接口:

struct eventpoll {
// ...
struct rb_root rbr; // 管理 epoll 监听的事件
struct list_head rdllist; // 保存着 epoll_wait 返回满⾜条件的事件
// ...
};
struct epitem {
// ...
struct rb_node rbn; // 红⿊树节点
struct list_head rdllist; // 双向链表节点
struct epoll_filefd ffd; // 事件句柄信息
struct eventpoll *ep; // 指向所属的eventpoll对象
struct epoll_event event; // 注册的事件类型
// ...
};
struct epoll_event {
__uint32_t events; // epollin epollout epollel(边缘触发)
epoll_data_t data; // 保存 关联数据
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
int epoll_create(int size);
/**
op:
EPOLL_CTL_ADD
EPOLL_CTL_MOD
EPOLL_CTL_DEL
event.events:
EPOLLIN 注册读事件
EPOLLOUT 注册写事件
EPOLLET 注册边缘触发模式,默认是水平触发
*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
/**
events[i].events:
EPOLLIN 触发读事件
EPOLLOUT 触发写事件
EPOLLERR 连接发生错误
EPOLLRDHUP 连接读端关闭
EPOLLHUP 连接双端关闭
*/
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

调用 epoll_create 会创建一个 epoll 对象;调用 epoll_ctl 添加到 epoll 中的事件都会与网卡驱动程序建立回调关系,相应事件触发时会调用回调函数 ( ep_poll_callback ),将触发的事件拷贝到 rdlist 双向链表中;调用 epoll_wait 将会把 rdlist 中就绪事件拷贝到用户态 中; 20230110203337

Reactor

其将对IO的处理,转化为对事件的处理

组成:IO多路复用+非阻塞IO

单reactor

一个reactor用管理listenfd和connfd

20230110203956

redis网络封装:

20230109220022

ae:async event anet:异步网络 connection:redis-cli和redis-server的数据处理 networking:协议处理 (redis协议、io多线程)

redis源码没读完,以后补上解读连接

io多线程

网络是单线程,但io是多线程,这也就是redis为什么单线程还这么快的原因。 以读事件举例:

int n=read(fd,buf,sz);
cmd,args=decode(buf,sz);  // 数据处理

out=logic(cmd,args);   // 业务逻辑处理

reply=encode(out);
write(fd,reply,sizeof(reply));   // 数据处理

redis6.0的单reactor的io多线程优化:

  • 将read和decode做为任务插入到读队列中,利用负载均衡(取余),将这个队列的任务分配到不同的IO线程中去处理
  • 一个连接read和decode做完后,将其返回结果交给主线程,进行业务逻辑处理
    • 因为在reactor循环中,读写很耗CPU,很容易把后面的连接卡死了,所以把耗时的操作进行异步处理
  • 主线程处理后,将encode和write插入到写队列,分配到不同的io线程去处理,处理完后进入主线程

优化2: 单线程读,多线程写,由于读缓冲区可能只会读一部分,多线程读一个缓冲区可能造成顺序错乱;但写操作是一次性,要么全部写入,要么写入失败,不会产生顺序错乱。

多线程reactor:one eventloop per thread

如memcache

20230110204030

reactor中可以有多个epoll,可以用一个处理连接(mysql也是用一个select处理连接),然后由这个reactor将连接分配到不同的reactor,reactor的数量和CPU的核心数相关

当每个连接有较多的交互,临界资源较多以至于加锁麻烦时,性能不如单reactor

多进程reactor

如Nigix:

20230110204049

一个master进程fork出多个worker进程,每个进程都有一个reactor,每个worker进程都监听在一个端口上(因为是fork出来的),连接都放在共享内存里,哪个进程拿到了这把锁,谁就accept这个连接,并交由这个进程的reactor处理。

由于nginx内一个进程只有一个线程,所以也是one eventloop per thread

与redis和memcached不同,nginx采用边缘触发,因为nginx主要用来做反向代理,把这条连接的数据转发给不同的客户端服务器,它自己又不用处理数据,所以用边缘触发效率更高;

而redis要做业务开发,全部读完处理不过来,而且没必要一次全读完,反正下次会继续触发。