网络io与多路复用select/ poll/epoll 笔记
重点:socket与文件描述符的关联,多路复用与select/poll 代码实现LT/ET的区别
文章目录
1. 网络io
数据在网络空间的发送必然涉及到读写, 两个设备要通讯必然要建立连接, 而网络io就是这个连接,。
网络io: 你发数据别人接受数据就是网络io
2.socket(套接字)
socket是网络传输用的软件设备,它封装了一些接口方便我们操作。
详情阅读:
服务端套接字创建流程:
- 第一步:调用socket函数接口
- 第二步:调用bind函数分配IP地址和端口号
- 第三步:调用listen函数转为可接受请求状态
- 第四步:调用accept函数受理连接请求
客户端套接字创建流程:
- 第一步:调用socket函数创建套接字
- 第二部:调用connect函数向服务器端发送连接请求
(接受recv/发送send)
3.文件描述符
为了方便起了个别名 (文件描述符)fd 表示一个io,Linux把io都当作文件来操作。
fd(0,1,2)已经分配给系统,后面获得的fd都是依次递增的。
当连接断开原fd在一定时间不会重新分配(系统默认60s,你可以进行设置让一些fd可以快速回收),时间过了之后就可以重新设置了。
4.socket与文件描述符的关联
一些好用的指令:
查看端口:netstat -anop | grep 2000
从代码上可以知道listen后端口就存在了
查看端口:lsof -i:port
socket连接的现象
1.端口已经被绑定不可再次绑定
2.执行的listen,就可以通过netstat看到io的状态
3.进入listen后就可以被连接,并且会产生新的连接状态 (有io没分配)
4.io与tcp连接
netstat -anop | grep 2000 可以看到下面有两条信息说明代码产生两个fd
信息中fd与TCP连接信息1对1,多少个就对应多少个
fd(又叫io)与tcp连接不同。但在使用accpet的时候它们生命周期很近,没有要分开讲。
socket与文件描述符的关联:
1.listen就可以建立连接,但是建立连接是没有获得客户端的文件描述符(fd)
2.accept()的作用就是获取已经建立连接的文件描述符(fd)
ps:连接不代表获得fd,获得了fd才能进行操作
5.多线程实现io的处理(1请求1线程)
accept只能处理1个socket获得1个fd,如果直接使用循环:
-
一个连接只能发一次,如果同一个客户端要再次发送要断开连接再连接1次。
-
因为每次accpet后都会产生一个新的fd,所以需要关闭。
-
还要按照连接顺序来发送数据,否则根本发不了
-
因为它先accept后就阻塞了,后面再accept的客户端根本收不到数据,要等连接的发完才别的才能发
#include <iostream>
#include <unistd.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <pthread.h>
#include <string>
#include <poll.h>
#include <sys/epoll.h>
int main(){
// 服务员
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 安排位置
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
// INADDR_ANY = 0.0.0.0 是通配符,表示任意地址,可以监听任意地址的端口
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(2000);
// 占好位置
if ( bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0 ) {
cout << "bind error" << endl;
};
// 开始监听(可以点菜)
printf( "listening...\n" );
listen(sockfd, 10);
struct sockaddr_in cli_addr;
socklen_t len = sizeof(cli_addr);
#if 0
// 选择顾客(点菜)
printf( "waiting for client...\n" );
int clientfd = accept(sockfd, (struct sockaddr*)&cli_addr, &len);
char buffer[1024] = {0};
printf("recv start\n");
int count = recv(clientfd, buffer, 1024, 0);
printf("recv: %s\n", buffer);
count = send(clientfd, "00000", 5, 0);
printf( "send: %d\n", count );
#elif 0
// 这个一个连接只能发一次,如果同一个客户端要再次发送要断开连接再连接1次
// 因为每次accpet后都会产生一个新的fd,所以需要关闭
// 而且还要按照连接顺序来发送数据,否则根本发不了
// 因为它先accept后就阻塞了,后面再accept的客户端根本收不到数据,要等连接的发完才别的才能发
while(1){
printf( "waiting for client...\n" );
int clientfd = accept(sockfd, (struct sockaddr*)&cli_addr, &len);
char buffer[1024] = {0};
printf("recv start\n");
int count = recv(clientfd, buffer, 1024, 0);
if (count == 0) { // disconect
printf("client close\n");
close(clientfd);
break;
}
printf("recv: %s\n", buffer);
count = send(clientfd, "00000", 5, 0);
printf( "send: %d\n", count );
}
return 0;
}
这样是不太好的我们就利用多线程来解决上面这个问题。
只要accpet接受到一个fd,我们就单独分配一个线程来对这个fd进行处理。这样这个fd的处理就不会影响到其他fd。
客户端断开的话,recv接收到的为0。
void* client_thread(void *arg){
int clientfd = *(int *)arg;
while(1){
char buffer[1024] = {0};
int count = recv(clientfd, buffer, 1024, 0);
if (count == 0) { // disconect
printf("client close\n");
close(clientfd);
break;
}
printf("recv: %s\n", buffer);
count = send(clientfd, "00000", 5, 0);
printf( "send: %d\n", count );
}
}
1请求1线程的代码
while(1){
printf( "waiting for client...\n" );
int clientfd = accept(sockfd, (struct sockaddr*)&cli_addr, &len);
printf("%d\n",clientfd);
pthread_t tid;
pthread_create(&tid, NULL, client_thread, &clientfd);
}
优点:逻辑简单
缺点:大并发太消耗资源根本无法支持。
6. io多路复用
epoll 用于大并发 poll小并发(1-2) 无poll才使用select
6.1 select
select的内容:
1.fd_set 结构体类型集合
2.FD_ZERO 清空fd集合
3.FD_SET 设置fd,相当于初始化
4.FD_ISSET FD_ISSET(sockfd, &rset)就是判断sockfd是否在集合(可读,可写,错误三个集合)中
5.select 轮询找到满足条件的fd,它是内核调用会copy fdset, 没满足条件就会阻塞在这里。
fd_set是一个bit位集合,默认1024个:
8是一个字节有8个比特 eg:long是4个字节 1024 / (8 * 4)= 32
这个代码就是在算这个类型要多少个才能表示1024个比特位。这个可以改来增加操作fd的个数。
select的流程:
rfds 放置现存的所有io
rset 是根据集合筛选出来的可使用的
select 进行轮询找能用的io集合
// ======== select =========
// 1.优点:实现了io多路复用
// 2.缺点:参数太多而且麻烦
fd_set rfds, rset;
FD_ZERO(&rfds); // 清空fd
FD_SET(sockfd, &rfds); // fd设置, 像初始化
int maxfd = sockfd; // 记录最大fd
while(1){
rset = rfds;
// rfds是应用层用的用了存的,rset是内核用的是用来判断的
// 参数
// 1.max fd作用是用来做循环条件 fd + 1 (因为是从0开始的)
// 我们关注的条件,加rfds传入,如果满足条件,则返回满足条件的fd,和它的数量nready
// 这就是为什么io(fd)要设置一个集合,其实就是在集合中找满足条件的最终子集
// 2.第一个参数,是读集合,也就是fd_set结构体
// 3.第二个参数,是写集合,也就是fd_set结构体
// 4.第三个参数,是读集合,也就是fd_set结构体
// 5.第5个参数,是超时时间,如果为NULL,则一直阻塞
int nready = select(maxfd+1, &rset, NULL, NULL, NULL); // 阻塞在这里,只要io可读就返回不然就一直阻塞
// select也是一个系统调用,每一次都要把fd的集合传入内核,然后内核再循环判断io是否就绪
// 2.SOCKFD是第几位是否已经被设置
// FD_ISSET(sockfd, &rset)就是判断sockfd是否在集合中
if (FD_ISSET(sockfd, &rset)) {
printf("accept\n");
int clientfd = accept(sockfd, (struct sockaddr*)&cli_addr, &len);
printf("%d\n",clientfd);
FD_SET(clientfd, &rfds); // 添加到集合中
if (maxfd < clientfd) maxfd = clientfd;
}
// recv
int i = 0;
for (i = sockfd + 1; i <= maxfd; ++i) {
if (FD_ISSET(i, &rfds)) {
printf("recv\n");
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
if (count == 0) { // disconect
printf("client close\n");
close(i);
FD_CLR(i, &rfds);
continue;
} else {
printf("recv: %s\n", buffer);
}
count = send(i, buffer, count, 0);
}
}
}
6.2 poll
poll的内容:(感觉在poll的时候就多了事件这个概念,这是select所没有的)
1.结构体 pollfd
events是传入的事件
revents是返回的事件
相关宏定义(用来判断)
2.poll是系统调用,把pollfd的fd集合放到到内核。内核循环看io是否就绪,不就绪进行阻塞。
3.poll的底层是还是select那一套,但是参数会少些。
poll的流程:
初始化pollfd结构体,设置好fd和对应事件
poll(fds, maxfd+1, -1); // 阻塞在这里,只要io可读就返回
if (fds[sockfd].revents & POLLIN) // 判断是否是想要的事件
#elif 0
// ========= poll =========
struct pollfd fds[1024] = {0};
int n = 0;
fds[sockfd].fd = sockfd; // 监听fd
fds[sockfd].events = POLLIN; // 监听的事件
int maxfd = sockfd;
while(1){
// 参数
// 1.监听的个数
// 2.监听的集合,也就是pollfd结构体数组
// 3.超时时间,如果为NULL,则一直阻塞, -1表示一直阻塞
int nready = poll(fds, maxfd+1, -1); // 阻塞在这里,只要io可读就返回
if (fds[sockfd].revents & POLLIN){
printf("accept\n");
int clientfd = accept(sockfd, (struct sockaddr*)&cli_addr, &len);
fds[clientfd].fd = clientfd; // 监听fd
fds[clientfd].events = POLLIN; // 监听的事件
if (maxfd < clientfd) maxfd = clientfd;
}
// recv
int i = 0;
for (i = sockfd + 1; i <= maxfd; ++i) {
if (fds[i].revents & POLLIN) {
printf("recv\n");
char buffer[1024] = {0};
int count = recv(i, buffer, 1024, 0);
if (count == 0) { // disconect
printf("client close\n");
close(i);
fds[i].fd = -1;
fds[i].events = 0;
continue;
} else {
printf("recv: %s\n", buffer);
}
count = send(i, buffer, count, 0);
printf("send: %d\n", count);
}
}
}
6.3 epoll
epoll使得大并发连接成为可能。
epoll的实现类比为送快递:
1.epoll实现上就是多了一个中转,原本fd是否可用都是轮询一个个询问(快递员一个个问是否有快递)。
2.而epoll是建立中转站(eg:丰巢),请求只要都往中转站里去放就绪,快递员只要定时来查询中转站就好,不用挨家挨户一个个查询(不用遍历所有fd)
epoll内容
epoll_create: 就是雇佣快递员 建立丰巢盒子。(建立就绪)
快递员就不用去找住户了,直接把快递放丰巢用户(io)自己来拿快递。
快递员只要定时来丰巢取快递和送快递就ok。
住户是整集,丰巢是就绪(用什么数据结构组织?)
(io)住户 --> 丰巢 <–快递员
epoll_ctl:是住户的搬出和搬入,改变了内部的位置
下面是相关操作码
EPOLL_CTL_ADD
EPOLL_CTL_DEL
EPOLL_CTL_MOD
epoll_wait:就是快递员多长时间去一次丰巢
evennts是取快递的快递箱
maxevents是最多取快递的数量
timeout就是间隔时间
struct epoll_event:
ev.data.fd = sockfd; // 监听fd
ev.events = EPOLLIN; // 监听的事件
epoll相比较select而言,对于大并发的优势在哪里??
1.能支持100w io
2.select(maxfd, 可读集合,可写集合,出错集合, err)100w的io,select要反复搬许多的io
epoll_create 后io是一个个添加,删除是一个个删除。
epoll的100w io是一点点加出来的, 积累
后面有io时间处理丰巢中的就绪就行
3.100w人同时在线不代表他们都同时发消息
每一个客户端对应一个io,100w中可能只要10w在发消息
就绪才是我们需要处理的事件,不用取处理100w更快
epoll流程
// ========= epoll =========
int epollfd = epoll_create(1);
struct epoll_event ev;
ev.data.fd = sockfd; // 监听fd
ev.events = EPOLLIN; // 监听的事件
// epoll_ctl参数
// 第一个参数,epollfd是epoll_create返回的文件描述符
// 第二个参数,EPOLL_CTL_ADD表示添加监听,EPOLL_CTL_DEL表示删除监听
// 第三个参数,监听的fd
// 第四个参数,epoll_event结构体指针
epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev);
int maxfd = sockfd;
while (1) {
struct epoll_event events[1024] = {0};
// 参数
// 1.epollfd
// 2.监听的个数
// 3.监听的集合,也就是epoll_event结构体数组
// 4.超时时间,如果为NULL,则一直阻塞, -1表示一直阻塞
int nready = epoll_wait(epollfd, events, maxfd+1, -1); // 阻塞在这里,只要io可读就返回
int i = 0;
for(i = 0; i < nready; ++i) {
int connfd = events[i].data.fd;
if (connfd == sockfd) { // 监听fd
printf("accept\n");
int clientfd = accept(sockfd, (struct sockaddr*)&cli_addr, &len);
printf("accept finished\n");
ev.data.fd = clientfd; // 监听fd
ev.events = EPOLLIN; // 监听的事件
epoll_ctl(epollfd, EPOLL_CTL_ADD, clientfd, &ev);
}else if(events[i].events & EPOLLIN){
char buffer[1024] = {0};
int count = recv(connfd, buffer, 1024, 0);
if(count == 0){
printf("client close\n");
close(connfd);
epoll_ctl(epollfd, EPOLL_CTL_DEL, connfd, NULL);
continue;
}
printf("recv: %s\n", buffer);
count = send(connfd, buffer, count, 0);
printf("send: %d\n", count);
}
}
// if (events[sockfd].events & EPOLLIN){
// printf("accept\n");
// int clientfd = accept(sockfd, (struct sockaddr*)&cli_addr, &len);
// ev.data.fd = clientfd; // 监听fd
// ev.events = EPOLLIN
// }
}
io多路复用总结
select,poll,epoll 三种方式,都是对server提供io事件进行触发,处理的是io。
代码实现LT/ET的区别
epoll有两种触发模式:
水平触发 (LT):buff中还有数据就一直触发
边沿触发 (ET):收到一次触发一次,so自己实现要加while循环,适合非阻塞的模式
event.events = EPOLLIN | EPOLLET; //ET 边沿触发模式
event.events = EPOLLIN; //默认 LT 水平触发模式 ()
适用场景:
边沿触发: 更适合一个包大小不确定的
水平触发: 更适合一个包大小比较确定的
零声链接:https://xxetb.xetslk.com/s/1ooNS8