socket网络编程进阶篇--------如何写一个并发的TCP服务器(基于IO复用)

Table of Contents

预备知识

 1. 什么是IO复用?

2.多线程和IO复用对比

具体过程

1.概述 

2.select函数

3.poll函数

​4.epoll函数

实例代码

1.基于select函数的服务器端

​​​2.基于poll实现的服务器端

参考:



在基础篇介绍了怎么从零开始写一个简单的tcp udp服务器,
在上一篇写的简单的tcp服务器中,他也叫循环服务器
对多个客户端的请求,同一时刻只能处理一个请求.但是每个请求处理的时候会稍微快一些.
在这一篇讲一讲并发tcp服务器如何实现,什么叫并发服务器呢?
对多个客户端的请求,同一时刻能处理多个请求,但是每个请求处理的时候会稍微慢一些
并发TCP服务器的实现可以有三种方式:多进程方式,多线程方式,多路复用方式
用多进程的方法也可以实现并发服务器,但是存在两个缺点
1.太占用cpu和内存资源,因为没来一个客户端请求就得创建一个进程.
2.实现进程间的通信(IPC)比较麻烦,提高了编程难度.
所以引入了第二种 ------基于IO复用方式实现的并发服务器.
再看下面的内容之前可先看前一篇博客 作为基础

预备知识

 1. 什么是IO复用?

io复用是五种网络IO模型中的一种,那这五种网络IO模型又具体指哪些呢?

https://blog.csdn.net/ocean_fan/article/details/79622956

2.多线程和IO复用对比

多线程模型适用于处理短连接,且连接的打开关闭非常频繁的情形,但不适合处理长连接。多线程模型默认情况下,(在Linux)每个线程会开8M的栈空间,假定有10000个连接,开这么多个线程需要80G的内存空间!即使调整每个线程的栈空间,也很难满足更多的需求。攻击者可以利用这一点发动DDoS,只要一个连接连上服务器什么也不做,就能吃掉服务器几M的内存,这不同于多进程模型,线程间内存无法共享,因为所有线程处在同一个地址空间中。内存是多线程模型的软肋。
在UNIX平台下多进程模型擅长处理并发长连接,但却不适用于连接频繁产生和关闭的情形。Windows平台忽略此项。 同样的连接需要的内存数量并不比多线程模型少,但是得益于操作系统虚拟内存的Copy on  Write机制,fork产生的进程和父进程共享了很大一部分物理内存。但是多进程模型在执行效率上太低,接受一个连接需要几百个时钟周期,产生一个进程 可能消耗几万个CPU时钟周期,两者的开销不成比例。而且由于每个进程的地址空间是独立的,如果需要进行进程间通信的话,只能使用IPC进行进程间通 信,而不能直接对内存进行访问。在CPU能力不足的情况下同样容易遭受DDos,攻击者只需要连上服务器,然后立刻关闭连接,服务端则需要打开一个进程再关闭。
同时需要保持很多的长连接,而且连接的开关很频繁,最高效的模型是非阻塞、异步IO模型。而且不要用select/poll,这两个API的有着O(N)的时间复杂度。在Linux用epoll,BSD用kqueue,Windows用IOCP,或者用libevent封装的统一接口(对于不同平台libevent实现时采用各个平台特有的API),这些平台特有的API时间复杂度为O(1)。然而在非阻塞,异步I/O模型下的编程是非常痛苦的。由于I/O操作不再阻塞,报文的解析需要小心翼翼,并且需要亲自管理维护每个链接的状态。并且为了充分利用CPU,还应结合线程池,避免在轮询线程中处理业务逻辑。 

具体过程

1.概述 

 上一篇介绍了简单TCP服务器实现的过程,咱们先来回顾一下整个服务器客户端的工作过程.
--->服务器用socket创建监听套接字(比如该套接字的文件描述符号为4)
-->调用listen函数监听该套接字
-->新来一个客户端调用connect请求连接
--->服务器调用accpet函数接受客户端请求并创建了针对这个客户端的数据传输套接字(比如文件描述符号为5) 
--->在这个套接字5上调用read函数读客户端发过来的数据
-->新来一个客户端调用connect请求连接
--->服务器调用accpet函数接受客户端请求并创建了针对这个客户端的数据传输套接字(比如文件描述符号为6) 
--->在这个套接字6上调用read函数读客户端发过来的数据
-->新来一个客户端调用connect请求连接
--->服务器调用accpet函数接受客户端请求并创建了针对这个客户端的数据传输套接字(比如文件描述符号为7) 
--->在这个套接字7上调用read函数读客户端发过来的数据
........(多个客户端请求的时候就不断延续上述过程) 

 上述的这样一个过程存在一个严重的问题
accpet函数在没有客户端连接时会阻塞,同样read/write函数在客户端没有数据传过来的也会阻塞
所以在上述多个客户端请求的过程中,
只要排队在上面的客户端没有传数据过来,调用read函数就会阻塞在那,后面继续到来的客户端请求就会响应不了
同样只要没有新的客户端连接过来,调用accept就会阻塞在那,前面发数据过来的客户端的请求也响应不了

这样根本就不能处理多个客户端的服务请求.
那怎么办呢?所以就引入了多路io复用,他有三种实现机制----select函数,poll函数,epoll函数

 

 

  • 对于select函数工作过程的理解

select函数是怎么工作的呢?我是这么理解的
上面这个服务器客户端交互过程本质上是文件io函数(accept/read)对文件描述符(4,5,6,7)的操作过程.
出现问题的罪魁祸首就是,
accep/read函数"自作主张"就直接去连接/读取文件描述符对应的套接字,
丝毫不管对应文件描述符的套接字是否可以连接/读取
万一对应文件描述符的套接字没有连接/数据请求传输过来,这两个函数自然就会阻塞.


理想的情况应该是有一个"监控员" ,
它可以告诉我们哪个文件描述符有连接过来了或者有数据传输过来了
这样我们就知道哪些文件描述符是可以连接或者读取的
这时候再调用accept/read函数就不会阻塞了


select就起了这样一个"监管员"的作用,在select函数的监管下,上述服务器/客户端交互过程变为如下
--->服务器用socket创建监听套接字(比如该套接字的文件描述符号为4)
-->调用listen函数监听该套接字
--->调用select函数开始监管套接字4,elect函数不断的循环查询套接字4,看在这个套接字上有没有外部连接
-->新来一个客户端调用connect请求连接 
--->通过select函数获知套接字4有客户端外部连接,可以用accept去处理套接字4了
--->服务器端调用accpet函数处理接受客户端请求并创建了针对这个客户端的数据传输套接字(比如文件描述符号为5) ,
--->将这个套接字5加入select的监管列表
--->重复上述过程,直至一共产生了套接字4,5,6,7,其中4为客户端连接请求套接字,5,6,7为客户端数据传输套接字,对应三个客户端
--->4,5,6,7套接字都在select的监管列表中,select函数不断的循环查询套接字4,5,6,7,看在这些套接字上有没有客户端外部请求

--->假设两个客户端传来了数据到套接字5,6
---->通过select函数获知套接字5,6有客户端数据传过来了,可以用read函数去处理套接字5,6了
---->服务器端调用read函数读取这两个套接字的数据
....(重复上述过程)


select特点分析
 

select优点: 
目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点 
select缺点: 
(1)每次调用 select(),都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大,需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。同时每次调用 select() 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大。 
(2)单个进程能够监视的文件描述符的数量存在最大限制,在 Linux 上一般为 1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低 
(3)select函数在每次调用之前都要对参数进行重新设定,这样做比较麻烦,而且会降低性能
  (4)对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。

具体API见下方讲解

 

 

  • 对于poll函数工作过程的理解

poll函数工作原理与select函数类似,也是监管一系列的文件描述符,看这些文件描述符是否可读/可写/异常,再去调用io函数读写
不过poll函数没有监管文件描述符个数的限制 ,与 select() 在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理。

poll函数的优缺点

1、优点
(1)poll() 不要求开发者计算最大文件描述符加一的大小。 
(2)poll() 在应付大数目的文件描述符的时候速度更快,相比于select。 
(3)它没有最大连接数的限制,原因是它是基于链表来存储的。 
(4)在调用函数时,只需要对参数进行一次设置就好了

2、缺点
(1)大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。 
(2)与select一样,poll返回后,需要轮询pollfd来获取就绪的描述符,这样会使性能下降 
(3)同时连接的大量客户端在一时刻可能只有很少的就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降
 

  • 对于epoll函数工作过程的理解

2.select函数

 根据上面对select函数使用过程的大致讲解,该函数的使用过程其实可以概括为三步

第一步:select函数问你:我要监管哪些套接字?
    这一步实际上是对select函数的参数初始化的一个过程,select函数长这样

#include 
/* According to earlier standards */
#include 
#include 
#include 
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
nfds 表示总共有几个要监管查询的套接字
fd_set是一种位数组类型,也就是说数组中的数组元素值只能是0或1
readfd表示要进行监管的读操作的套接字的数组,
writefds表示要进行监管的写操作套接字的数组
exceptfds暂时可以不管

怎么告诉selcet函数要监管哪些套接字描述符呢?就是把要监管的套接字描述符放到readfd,writefds数组中
那怎么把监管的套接字描述符放到readfd,writefds数组中呢?通过FD_SET(int fd, fd_set *set)函数; 把文件描述符fd加入到两个数组
                                                                                              因为fd_set是一个位数组,所以要把某个文件描述符加入监管,
                                                                                              就是把数组中该文件描述符位的元素置1,
                                                                                               这种置1操作就是通过FD_SET函数实现的
                                                                                               nfds表示总共要监管查询的套接字的个数.
这一步还有几个注意点
1.参数timeout表示每次查询停留的时间,
3种情况:设置为NULL,永远等下去,设置timeval,等待固定时间,设置timeval里时间均为0,检查描述字后立即返回,轮询
其中timeval结构体的格式如下

struct timeval {
long tv_sec; /* 秒数 */
long tv_usec; /* 毫秒数 */
}

2.readfd,writefds数组一开始需要全部置为0,通过void FD_ZERO(fd_set *set);函数进行此操作.
                         还有另外一个函数同样需要了解,FD_CLR(int fd, fd_set *set);把文件描述符集合里fd位清0

3.select能监听的文件描述符个数受限于FD_SETSIZE,一般为1024,单纯改变进程打开
的文件描述符个数并不能改变select监听文件个数,解决1024以下客户端时使用select是很合适的,但如果链接客户端过多,
select采用的是轮询模型,会大大降低服务器响应效率,不应在select上投入更多精力 

第二步:select函数开始工作,即开始查询这些文件描述符

调用select函数后,若发生错误则返回-1,若没有套接字可以读写则返回0,否则返回可以读写的套接字的个数,
大于0 表示有几个套接字是可以读写的,那么怎么知道是哪几个呢? 看第三步

第三步:你问select函数:有哪些套接字现在是可以读写的?
通过一个函数FD_ISSET(int fd, fd_set *set);这个函数意思就是看在set数组中是否存在fd描述符,
如果返回1 表示现在这个fd套接字是可读写的.如果返回0则相反

实例:
socket网络编程进阶篇--------如何写一个并发的TCP服务器(基于IO复用)_第1张图片
 

socket网络编程进阶篇--------如何写一个并发的TCP服务器(基于IO复用)_第2张图片

 

3.poll函数

 

poll函数的使用与select函数类似
第一步:poll函数问你:我要监管哪些套接字?
           这一步实际上也是对poll函数的参数初始化的一个过程,poll函数长这样

#include 
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
(1)fds:指向一个结构体数组的第0个元素的指针,每个数组元素都是一个struct pollfd结构,用于指定测试某个给定的fd的条件 
(2)nfds:表示fds结构体数组的长度 
(3)timeout:表示poll函数的超时时间,单位是毫秒 
函数功能: 
监视并等待多个文件描述符的属性变化 
函数返回值: 
(1)返回值小于0,表示出错 
(2)返回值等于0,表示poll函数等待超时 
(3)返回值大于0,表示poll由于监听的文件描述符就绪返回,并且返回结果就是就绪的文件描述符的个数。

与select采用位数组然后用FD_SET函数的形式不同,
poll函数中第一个参数引入了 pollfd这个结构体的数组,
数组中每一个元素就表示了,要监管的套接字,下面看看这个结构体具体长啥样
 

pollfd结构体的原型为:
struct pollfd {
   int   fd;         /* 文件描述符 */
   short events;     /* 注册的事件 */
   short revents;    /* 实际发生的事件,由内核填充 */
};

 

通过给pollfd结构的的fd成员赋值,同时的设定好events参数,表示就把这个文件描述符加到poll函数的监管列表去了.
与select函数不同的一点就是对不同文件描述符监管事件的确定方式不同.
select采用了三个类型的位数组分别表示哪些是可读是激活的文件描述符,哪些是可写时候激活,哪些是出异常的时候激活
poll函数用了events这个参数,在指定单个文件描述符的时候就指定要监管的事件(可读,可写还是异常)
下面介绍一下给events参数赋值的宏有哪些 

socket网络编程进阶篇--------如何写一个并发的TCP服务器(基于IO复用)_第3张图片
  

 a. POLLRDNORM(普通数据可读)、POLLRDBAND(优先级带数据可读)和POLLWRNORM(普通数据可写)、POLLWRBAND(优先级带数据可写)将POLLIN(数据可读)和POLLOUT(数据可写)划分得更明显,以区分优先级带数据和普通数据,但是Linux并不完全支持。 一般用POLLIN,POLLOUT,POLLERR就好
   b. 可同时设置一个文件描述符的多个监管事件,用按位或运算符连接就好了

poll函数的其他两个参数和select函数是类似的,
nfds表示监管的文件描述符的个数
timeout 毫秒级等待:-1表示阻塞等,#define INFTIM,0表示立即返回,不阻塞进程
                                >0表示等待指定毫秒数,如当前系统时间精度不够毫秒,向上取值
如果不再监控某个文件描述符时,可以把pollfd中,fd设置为-1,poll不再监控此pollfd,下次返回时,把revents设置为0。
  

第二步:poll函数开始工作,即开始查询这些文件描述符

调用poll函数后,若发生错误则返回-1,若没有套接字可以读写则返回0,否则返回可以读写的套接字的个数,
大于0 表示有几个套接字是可以读写的,那么怎么知道是哪几个呢? 看第三步

第三步:你问select函数:有哪些套接字现在是可以读写的?
select函数使用FD_ISSET函数判断某个套接字是否是激活状态
poll函数使用前面提到的pollfd结构体中的revents参数,revents变量在每一次poll函数调用完成后
              内核设置会设置revents的值,这个值其实也就是上面列出来的那些events的宏,以说明对该描述符发生了什么事件
              比如 调用完poll函数后要查看某一个文件描述符是否处于激活状态(比如可读) 
             是通过调用pollfd参数的revents参数与POLLIN做比较如果相等,则说明该文件描述符处现在是可读的
               使用if语句:if(poll_fd.revents==POLLIN)
例子:使用poll函数监控标准输入
 

  1 #include
  2 #include
  3 #include
  4 
  5 int main()
  6 {
  7     struct pollfd poll_fd;
  8     poll_fd.fd=0;
  9     poll_fd.events=POLLIN;
 10 
 11     for(;;)
 12     {
 13         int ret=poll(&poll_fd,1,2000);
 14         if(ret<0)
 15         {
 16             perror("poll");
 17             continue;
 18         }
 19         if(ret==0)
 20         {
 21             printf("poll timeout!\n");
 22             continue;
 23         }
 24         if(poll_fd.revents==POLLIN)
 25         {
 26             char buf[1024];
 27             read(0,buf,sizeof(buf)-1);
 28             printf("sdin:%s",buf);
 29         }
 30     }
 31 }

4.epoll函数

https://www.cnblogs.com/lojunren/p/3856290.html
https://blog.csdn.net/shenya1314/article/details/73691088



实例代码

1.基于select函数的服务器端

socket网络编程进阶篇--------如何写一个并发的TCP服务器(基于IO复用)_第4张图片

socket网络编程进阶篇--------如何写一个并发的TCP服务器(基于IO复用)_第5张图片

socket网络编程进阶篇--------如何写一个并发的TCP服务器(基于IO复用)_第6张图片
socket网络编程进阶篇--------如何写一个并发的TCP服务器(基于IO复用)_第7张图片
2.基于poll实现的服务器端
 

/* server.c */
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include "wrap.h"
#define MAXLINE 80
#define SERV_PORT 8000
#define OPEN_MAX 1024
int main(int argc, char *argv[])
{
int i, j, maxi, listenfd, connfd, sockfd;
int nready;
ssize_t
n;
char buf[MAXLINE], str[INET_ADDRSTRLEN];
socklen_t clilen;
struct pollfd client[OPEN_MAX];
struct sockaddr_in cliaddr, servaddr;
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
Bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
Listen(listenfd, 20);
client[0].fd = listenfd;
client[0].events = POLLRDNORM;
/* listenfd监听普通读事件 */
for (i = 1; i < OPEN_MAX; i++)
client[i].fd = -1;
/* 用-1初始化client[]里剩下元素 */
maxi = 0;
/* client[]数组有效元素中最大元素下标
for ( ; ; ) {
nready = poll(client, maxi+1, -1);
if (client[0].revents & POLLRDNORM) 
{
/* 阻塞 */
/* 有客户端链接请求 */
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (struct sockaddr *)&cliaddr, &clilen);
printf("received from %s at PORT %d\n",
inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
ntohs(cliaddr.sin_port));
for (i = 1; i < OPEN_MAX; i++)
if (client[i].fd < 0) {
client[i].fd = connfd;
/* 找到client[]中空闲的位置,存放accept返回的connfd */
break;
}
if (i == OPEN_MAX)
perr_exit("too many clients");
client[i].events = POLLRDNORM;
/* 设置刚刚返回的connfd,监控读事件 */
if (i > maxi)
maxi = i;
/* 更新client[]中最大元素下标 */
if (--nready <= 0)
continue;
/* 没有更多就绪事件时,继续回到poll阻塞 */
}
for (i = 1; i <= maxi; i++) {
/* 检测client[] */
if ( (sockfd = client[i].fd) < 0)
continue;
if (client[i].revents & (POLLRDNORM | POLLERR)) {
if ( (n = Read(sockfd, buf, MAXLINE)) < 0) {
if (errno == ECONNRESET) {
/* 当收到 RST标志时 */
/* connection reset by client */
printf("client[%d] aborted connection\n", i);
Close(sockfd);
client[i].fd = -1;
} else
perr_exit("read error");
} else if (n == 0) {
/* connection closed by client */
printf("client[%d] closed connection\n", i);
Close(sockfd);
client[i].fd = -1;
} else {
for (j = 0; j < n; j++)
buf[j] = toupper(buf[j]);
Writen(sockfd, buf, n);
}
if (--nready <= 0)
break;
}
}
}
return 0;
}

 

 

 

 



参考:
 

1: 
2.https://blog.csdn.net/qq_29344757/article/details/78775948
3,https://blog.csdn.net/qq_37964547/article/details/80697530

你可能感兴趣的:(网络编程)