IO多路复用技术 是一种 网络通信 的方式,通过这种方式可以同时检测多个 文件描述符(这个过程是阻塞的),一旦检测到某一个文件描述符(状态是 可读 或者 可写 的)是就绪的,那么就会解除阻塞,然后我们就可以基于这些已经就绪的文件描述符进行网络通信了。
通过这种方式,服务端即使是在 单线程/进程 的情况下也能实现并发,支持多个连接。
常用的 IO多路复用 的方式有 : select
、 poll
、epoll
。
主线程 / 父进程 调用 accept()
监测是否有客户端的连接到来:
子线程 / 子进程 调用 send() / write() , read() / recv()
和客户端建立的连接通信 :
read() / recv()
接收客户端发送过来的通信数据,如果 读缓冲区 中没有数据,那么当前 线程/进程 会阻塞,直到 读缓冲区 中有了数据,阻塞就会自动解除;send() / write()
向客户端发送数据,如果 写缓冲区 的容量已经满了,那么当前 线程/进程 会阻塞,直到 写缓冲区 有了空间,阻塞就会自动解除;使用 IO多路复用函数 会 委托内核 检测所有客户端的文件描述符 (主要是用于 监听 和 通信 的两类),这个过程会导致 进程/线程 的阻塞;如果内核检测到有就绪的文件描述符就会解除阻塞,然后将这些已经就绪的文件描述符传出去。
然后根据被传出的文件描述符的类型不同,做不同的处理:
1.用于监听的文件描述符 lfd
:用于和客户端建立连接
accept()
和客户端建立新连接是不会阻塞的,因为此时用于监听的文件描述符 lfd
是就绪的,也就是它对应的 读缓冲区 中有连接请求;2.用于通信的文件描述符 cfd
:调用通信函数 和 已经建立连接的客户端进行通信
read() / recv()
不会阻塞,因为此时用于通信的文件描述符 cfd
是就绪的,它对应的 读缓冲区 中有数据;send() / write()
不会阻塞,因为此时用于通信的文件描述符 cfd
是就绪的,它对应的 写缓冲区 中有多余的容量;3.对这些文件描述符继续进行下一轮检测,一直循环下去…
与 多线程/进程 技术相比,IO多路复用的优势在与不用频繁的进行 线程/进程的创建和销毁,不用管理 线程/进程,极大的减少了资源的消耗。
select
是跨平台的,同时支持 Linux
、 Windows
、MacOS
。我们通过调用 select()
这个函数就可以委托内核帮助我们检测 文件描述符 的状态,也就是检测这些文件描述符对应的 读写缓冲区 的状态:
内核检测完毕文件描述符的状态之后,已经就绪的文件描述符会通过 select()
的三个参数传出,这三个参数都是一个 集合,我们得到之后就能对其进行处理。
#include
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval * timeout);
nfds
: 委托内核检测的这三个集合中 最大的文件描述符 + 1
Windows
中这个参数是无效的,直接指定为 − 1 -1 −1 即可;readfds
:文件描述符的集合,内核只检测这个集合中的文件描述符对应的 读缓冲区
wtitedfs
:文件描述符的集合,内核只检测这个集合中的文件描述符对应的 写缓冲区
NULL
;exceptdfs
:文件描述符的集合,内核只检测这个集合中的文件描述符是否有异常状态
NULL
;timeout
:超时时长,用来强制解除 select()
的阻塞的
NULL
的话,select()
检测不到就绪的文件描述符就会一直阻塞;select()
检测不到就绪的文件描述符,在指定时长之后强制解除阻塞,返回 0 0 0;select()
函数就不会阻塞;返回值:
需要使用到的一些函数:
// 将文件描述符fd从set集合中删除 == 将fd对应的标志位设置为0
void FD_CLR(int fd, fd_set *set);
// 判断文件描述符fd是否在set集合中 == 读一下fd对应的标志位到底是0还是1
int FD_ISSET(int fd, fd_set *set);
// 将文件描述符fd添加到set集合中 == 将fd对应的标志位设置为1
void FD_SET(int fd, fd_set *set);
// 将set集合中, 所有文件文件描述符对应的标志位设置为0, 集合中没有添加任何文件描述符
void FD_ZERO(fd_set *set);
在 select()
函数中第2、3、4个参数都是 fd_set
类型,它表示一个文件描述符的集合,这个类型的数据有 128 128 128 个字节,也就是 1024 1024 1024 个标志位,和内核中文件描述符表中的文件描述符个数是一样的。
sizeof(fd_set) = 128 字节 * 8 = 1024 bit // int [32]
这块内存中的每一个bit 和 文件描述符表中的每一个文件描述符是一一对应的关系,这样就可以使用最小的存储空间将要表达的意思描述出来了。
下图中的 fd_set
存储了要委托内核要检测的 读缓冲区的文件描述符集合。
内核在遍历这个 读集合 的过程中,如果被检测的文件描述符对应的读缓冲区中 没有数据,内核将修改这个文件描述符在读集合 fd_set
中对应的标志位,改为 0 0 0,如果有数据那么这个标志位的值不变,还是 1 1 1。
当 select()
函数解除阻塞之后,被内核修改过的 读集合 通过参数传出,此时集合中只要标志位的值为 1 1 1,那么它对应的文件描述符肯定是就绪的,我们就可以基于这个文件描述符和客户端建立新连接或者通信了。
1.创建监听的套接字 lfd = socket()
;
2.将 监听的套接字 和 本地的 ip 和 端口 绑定 bind()
;
3.给监听的套接字设置监听 listen()
;
4.创建一个文件描述符集合 fd_set
,用于存储需要检测读事件的所有的文件描述符
通过 FD_ZERO()
初始化;
FD_SET()
将监听的文件描述符放入检测的读集合中select()
,周期性的对所有的文件描述符进行检测5.select()
解除阻塞返回,得到内核传出的满足条件的就绪的文件描述符集合
6.通过 FD_ISSET()
判断集合中的标志位是否为 1 1 1
accept()
和客户端建立连接。将得到的新的通信的文件描述符,通过 FD_SET()
放入到检测集合中FD_CLR()
将这个文件描述符从检测集合中删除7.重复第6步
客户端代码:
#include
#include
#include
#include
#include
int main(){
//1.创建用于通信的文件描述符 cfd
int cfd = socket(AF_INET,SOCK_STREAM,0);
if(cfd == -1){
perror("socket");
return -1;
}
printf("1.成功创建了用于通信的文件描述符 : %d\n",cfd);
//2.连接服务器
unsigned short port = 10000;
//你自己的服务器 或者 虚拟机的 ip地址
const char* ip = "10.0.8.14";
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
inet_pton(AF_INET,ip,&addr.sin_addr.s_addr);
int ret = connect(cfd,(struct sockaddr*)&addr,sizeof(addr));
if(ret == -1){
perror("connet");
return -1;
}
printf("2.成功连接了服务器 , ip : %s , port : %d\n",ip,port);
//3.开始通信
char send_buf[1024];
char recv_buf[1024];
int cnt = 0;
while(1){
memset(send_buf,0,sizeof send_buf);
memset(recv_buf,0,sizeof recv_buf);
sprintf(send_buf,"hello i love you : %d",cnt++);
//发送数据
send(cfd,send_buf,strlen(send_buf) + 1,0);
//接收数据
int len = recv(cfd,recv_buf,sizeof(recv_buf),0);
if(len > 0){
printf("服务端 : %s\n",recv_buf);
}
else if(len == 0){
printf("服务端已经断开了连接...\n");
break;
}
else{
perror("recv");
break;
}
sleep(1);
}
close(cfd);
return 0;
}
#include
#include
#include
#include
#include
#include
#include
#include
int main()
{
// 1.创建用于监听的文件描述符
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd == -1)
{
perror("socket");
return -1;
}
// 2.绑定 ip 和 端口号
unsigned short port = 10000;
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(port);
saddr.sin_addr.s_addr = INADDR_ANY;
int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
if (ret == -1)
{
perror("bind");
return -1;
}
// 3.设置监听
ret = listen(lfd, 128);
if (ret == -1)
{
perror("listen");
return -1;
}
printf("设置监听成功...\n");
// 4.获取连接
fd_set readset ,temp;
FD_ZERO(&readset);
//把用于监听的文件描述符 lfd , 加入到 readset 读集合中
FD_SET(lfd,&readset);
int addr_len = sizeof(struct sockaddr_in);
int maxfd = lfd;
char buf[1024];
char* str = "ok";
while(1){
temp = readset;
int ret = select(maxfd + 1,&temp,NULL,NULL,NULL);
//检测 用于监听的文件描述符 lfd 读缓冲区是否有数据 , 也就是是否有新的连接到来
if(FD_ISSET(lfd,&temp)){
struct sockaddr_in addr;
int cfd = accept(lfd,(struct sockaddr*)&addr,&addr_len);
//将用于通信文件描述符 cfd 也加入到 读集合中
FD_SET(cfd,&readset);
//更新 maxfd
maxfd = cfd > maxfd ? cfd : maxfd;
printf("获取连接成功 , 客户端 ip : %s , port : %d\n", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port));
}
for(int i = 0;i <= maxfd;i++){
//用于通信的文件描述符 cfd 的读缓冲区有数据了 , 也就是有客户端发消息过来了
if(i != lfd && FD_ISSET(i,&temp)){
memset(buf,0,sizeof buf);
int len = read(i,buf,sizeof buf);
printf("客户端 : %s\n",buf);
if(len > 0){
write(i,str,strlen(str) + 1);
}
else if(len == 0){
//客户端已经关闭了连接
printf("客户端已经关闭了连接...\n");
FD_CLR(i,&readset);
close(i);
}
else{
perror("read");
}
}
}
}
close(lfd);
return 0;
}
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
pthread_mutex_t mutex;
char buf[1024];
typedef struct fdinfo
{
int fd;
int *maxfd;
fd_set *readset;
} FdInfo;
void *acceptConnection(void *arg)
{
printf("子线程的线程id为 : %ld\n", pthread_self());
FdInfo *info = (FdInfo *)(arg);
int lfd = info->fd;
struct sockaddr_in addr;
int addr_len = sizeof(addr);
int cfd = accept(lfd, (struct sockaddr *)&addr, &addr_len);
pthread_mutex_lock(&mutex);
// 将用于通信文件描述符 cfd 也加入到 读集合中
FD_SET(cfd, info->readset);
// 更新 maxfd
*info->maxfd = cfd > *info->maxfd ? cfd : *info->maxfd;
pthread_mutex_unlock(&mutex);
printf("获取连接成功 , 客户端 ip : %s , port : %d\n", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port));
free(info);
return NULL;
}
void *communicate(void *arg)
{
printf("子线程的线程id为 : %ld\n", pthread_self());
FdInfo *info = (FdInfo *)(arg);
int cfd = info->fd;
memset(buf, 0, sizeof buf);
int len = read(cfd, buf, sizeof buf);
printf("客户端 : %s\n", buf);
if (len < 0)
{
perror("read");
free(info);
return NULL;
}
else if(len == 0){
// 客户端已经关闭了连接
printf("客户端已经关闭了连接...\n");
pthread_mutex_lock(&mutex);
FD_CLR(cfd, info->readset);
pthread_mutex_unlock(&mutex);
close(cfd);
free(info);
return NULL;
}
int str_len = strlen(buf);
for(int i = 0;i < str_len;i++) buf[i] = toupper(buf[i]);
write(cfd,buf,len);
free(info);
return NULL;
}
int main()
{
// 初始化 mutex
pthread_mutex_init(&mutex, NULL);
// 1.创建用于监听的文件描述符
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd == -1)
{
perror("socket");
return -1;
}
// 2.绑定 ip 和 端口号
unsigned short port = 10000;
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(port);
saddr.sin_addr.s_addr = INADDR_ANY;
int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
if (ret == -1)
{
perror("bind");
return -1;
}
// 3.设置监听
ret = listen(lfd, 128);
if (ret == -1)
{
perror("listen");
return -1;
}
printf("设置监听成功...\n");
// 4.获取连接
fd_set readset, temp;
FD_ZERO(&readset);
// 把用于监听的文件描述符 lfd , 加入到 readset 读集合中
FD_SET(lfd, &readset);
int addr_len = sizeof(struct sockaddr_in);
int maxfd = lfd;
while (1)
{
pthread_mutex_lock(&mutex);
temp = readset;
pthread_mutex_unlock(&mutex);
int ret = select(maxfd + 1, &temp, NULL, NULL, NULL);
// 检测 用于监听的文件描述符 lfd 读缓冲区是否有数据 , 也就是是否有新的连接到来
if (FD_ISSET(lfd, &temp))
{
FdInfo *info = (FdInfo *)malloc(sizeof(FdInfo));
info->fd = lfd;
info->maxfd = &maxfd;
info->readset = &readset;
pthread_t tid;
pthread_create(&tid, NULL, acceptConnection, info);
pthread_detach(tid);
}
for (int i = 0; i <= maxfd; i++)
{
// 用于通信的文件描述符 cfd 的读缓冲区有数据了 , 也就是有客户端发消息过来了
if (i != lfd && FD_ISSET(i, &temp))
{
FdInfo *info = (FdInfo *)malloc(sizeof(FdInfo));
info->fd = i;
info->maxfd = &maxfd;
info->readset = &readset;
pthread_t tid;
pthread_create(&tid, NULL, communicate, info);
pthread_detach(tid);
}
}
}
close(lfd);
pthread_mutex_destroy(&mutex);
return 0;
}
客户端不需要使用IO多路复用进行处理,因为客户端和服务器的对应关系是 1 : N 1:N 1:N,也就是说客户端是比较专一的,只能和一个连接成功的服务器通信。
虽然使用select这种IO多路转接技术可以降低系统开销,提高程序效率,但是它也有局限性:
select()
传递进来的待检测集合的检测方式是线性的select
能够检测的最大文件描述符个数有上限,默认是 1024 1024 1024,这是在内核中被写死了的。poll
的机制跟 select
类似,使用方法也是类似的,以下是它们两个的对比:
poll
和 select
检测的文件描述符的集合 会在被检测的过程频繁的进行 用户区 和 内核区的拷贝,它的开销随着文件描述符数量的增加而增大,所以效率也会变得越来越低;select
可以跨平台使用,支持 Linux
、Windows
、MacOS
;而 poll
只能在 Linux
平台下使用;#include
// 每个委托poll检测的fd都对应这样一个结构体
struct pollfd {
int fd; /* 委托内核检测的文件描述符 */
short events; /* 委托内核检测文件描述符的什么事件 */
short revents; /* 文件描述符实际发生的事件 -> 传出 */
};
struct pollfd myfd[100];
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
fds
:这是一个 struct pollfd
类型的数组,里面存储了待检测的文件描述符的信息,它一共有三个成员:
fd
: 委托内核检测的文件描述符;events
:委托内核检测的文件描述符对应的事件(读,写,错误);revents
:这是一个传出参数,数据由内核写入,存储内核检测之后的结果;nfds
:这是第一个参数数组中最后一个元素的下标 + 1,用来表示循环结束的条件;
timeout
:指定 poll()
函数的阻塞时长:
函数返回值:
客户端代码:
#include
#include
#include
#include
#include
int main(){
//1.创建用于通信的文件描述符 cfd
int cfd = socket(AF_INET,SOCK_STREAM,0);
if(cfd == -1){
perror("socket");
return -1;
}
printf("1.成功创建了用于通信的文件描述符 : %d\n",cfd);
//2.连接服务器
unsigned short port = 10000;
const char* ip = "10.0.8.14";
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
inet_pton(AF_INET,ip,&addr.sin_addr.s_addr);
int ret = connect(cfd,(struct sockaddr*)&addr,sizeof(addr));
if(ret == -1){
perror("connet");
return -1;
}
printf("2.成功连接了服务器 , ip : %s , port : %d\n",ip,port);
//3.开始通信
char send_buf[1024];
char recv_buf[1024];
int cnt = 0;
while(1){
memset(send_buf,0,sizeof send_buf);
memset(recv_buf,0,sizeof recv_buf);
sprintf(send_buf,"hello i love you : %d",cnt++);
//发送数据
send(cfd,send_buf,strlen(send_buf) + 1,0);
//接收数据
int len = recv(cfd,recv_buf,sizeof(recv_buf),0);
if(len > 0){
printf("服务端 : %s\n",recv_buf);
}
else if(len == 0){
printf("服务端已经断开了连接...\n");
break;
}
else{
perror("recv");
break;
}
sleep(1);
}
close(cfd);
return 0;
}
服务端代码:
#include
#include
#include
#include
#include
#include
#include
#include
#include
int main()
{
// 1.创建用于监听的文件描述符
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd == -1)
{
perror("socket");
return -1;
}
// 2.绑定 ip 和 端口号
unsigned short port = 10000;
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(port);
saddr.sin_addr.s_addr = INADDR_ANY;
int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
if (ret == -1)
{
perror("bind");
return -1;
}
// 3.设置监听
ret = listen(lfd, 128);
if (ret == -1)
{
perror("listen");
return -1;
}
printf("设置监听成功...\n");
// 4.获取连接
struct pollfd fds[1024];
for (int i = 0; i < 1024; i++)
{
fds[i].fd = -1;
fds[i].events |= POLLIN;
}
// 把用于监听的文件描述符 lfd 加入到 fds 数组中
fds[0].fd = lfd;
int addr_len = sizeof(struct sockaddr_in);
int maxfd = 0;
char buf[1024];
char *str = "ok";
while (1)
{
int ret = poll(fds, maxfd + 1, -1);
if(ret == -1){
perror("poll");
exit(0);
}
// 检测 用于监听的文件描述符 lfd 读缓冲区是否有数据 , 也就是是否有新的连接到来
if (fds[0].revents & POLLIN)
{
struct sockaddr_in addr;
// 获取连接 , 返回用于通信的文件描述符
int cfd = accept(lfd, (struct sockaddr *)&addr, &addr_len);
printf("获取连接成功 , 客户端 ip : %s , port : %d\n", inet_ntoa(addr.sin_addr), ntohs(addr.sin_port));
// 把用于通信的文件描述符 cfd 加入到 fds 中
int i = 1;
for (; i < 1024; i++)
{
if (fds[i].fd == -1)
{
fds[i].fd = cfd;
break;
}
}
maxfd = i > maxfd ? i : maxfd;
}
for (int i = 1; i <= maxfd; i++)
{
// 用于通信的文件描述符 cfd 的读缓冲区有数据了 , 也就是有客户端发消息过来了
if (fds[i].revents & POLLIN)
{
int cfd = fds[i].fd;
memset(buf, 0, sizeof buf);
int len = read(cfd, buf, sizeof buf);
printf("客户端 : %s\n", buf);
if (len > 0)
{
write(cfd, str, strlen(str) + 1);
}
else if (len == 0)
{
// 客户端已经关闭了连接
printf("客户端已经关闭了连接...\n");
close(cfd);
fds[i].fd = -1;
}
else
{
perror("read");
exit(0);
}
}
}
}
close(lfd);
return 0;
}