前言
TCP服务器的运行模型
TCP中,第一个客户端和服务器端建立连接,向服务器端不发数据,服务器端就在recv阻塞住,无法继续执行;如果有第二个客户端与服务器端建立连接,就在已完成三次握手的队列中放着,等着accept处理它,由于我们的代码阻塞在recv,没有机会去执行accept,导致第二个客户端得不到响应,我们之前的解决方法是accept之后的代码在子线程中执行。多线程多进程解决此问题。
根据我们对TCP编程流程的理解:
由图可知,客户端1
将首先与服务器建立连接,此时客户端2
和3
也想和服务器建立连接,因为服务器已经与客户端1
建立连接并进行交互,所以客户端2
和3
只能在listen()创建的内核等待队列中等待,因此就产生了一个问题:
一个TCP服务器程序只能同时和一个客户端进行交互,其他链接上的客户端只能在TCP服务器的内核队列中等待。
I/O复用
IO复用使得程序能够同时监听多个文件描述符,这对于提升程序的性能至关重要。
IO即为网络I/O,多路即为多个TCP连接,复用即为共用一个线程或者进程,模型最大的优势是解决描述符过多的问题,系统开销小,不必创建也不必维护过多的线程或进程。
我们用I/O函数先去检测 所有的描述符,挑谁上面有数据,把这些检测出来,对这些有数据的描述符来个循环,依次处理,依次recv处理,都不会阻塞,因为都有数据,select检测没有数据的描述符我们不去recv处理
通常,网络程序在下列情况下需要使用I/O复用技术:
- 客户端程序要同时处理多个socket。
- 客户端程序要同时处理用户输入和网络连接。(聊天室程序)
- TCP服务器要同时处理监听socket和连接socket,这是I/O复用使用最多的场合。
- 服务器要同时处理TCP请求和UDP请求。
- 服务器要同时监听多个端口,或者处理多种服务。
需要指出的是:
IO复用虽然能同时监听多个文件描述符,但它
本身是阻塞的
。并且当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器程序看起来像是串行工作的。如果要实现并发,只能使用多进程或多线程等编程手段。
下面就是添加了IO复用的TCP服务器模型:
如何做到的?在单个进程内同时处理多个描述符?
举个例子,假如学校要给你们发一本书,一种情况下,所有人到图书馆门前等,每个人就是一个线程,每个人都想要自己的数据,每个人都在等,阻塞住,如果谁的书到了,点名,谁就上去把书拿了,线程退出,线程往下执行。那么多人等,都阻塞住。还有一种情况,就留一个人在那等,谁的书发下来,那个人就给你打电话,你就去图书馆领取,这就是留一个人在那里等,就一个人阻塞住,先看有你没有数据,如果有数据,就通知你来处理这个数据,相当于代码执行recv,不会阻塞,因为你的数据已经到达了。
Linux上的I/O复用方式
select
poll
epoll
—— Linux独有的一种I/O复用方式
select原型
本质即一个Linux的系统调用方法
我们可以使用select函数实现I/O端口的复用,传递给 select函数的参数会告诉内核:
- 我们所关心的文件描述符
- 对每个描述符,我们所关心的状态。(我们是要想从一个文件描述符中读或者写,还是关注一个描述符中是否出现异常)
- 我们要等待多长时间。(我们可以等待无限长的时间,等待固定的一段时间,或者根本就不等待)
从 select函数返回后,内核告诉我们一下信息:
- 对我们的要求
已经做好准备的描述符的个数
- 对于三种条件哪些描述符已经做好准备.(读,写,异常)
有了这些返回信息,我们可以调用合适的I/O函数(通常是 read 或 write),并且这些函数不会再阻塞.
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
-
nfds
:所监听的所有文件描述符中的最大值 +1 ——> 提高内核执行效率,之后的描述符不用再访问了 -
如果在超时时间内没有任何文件描述就绪,select将返回0;
-
select失败返回n>0,,集合上有n个事件就绪,到底是哪几个事件就绪需要用户自己检查,那么怎么检测哪几个描述符上有事件就绪呢?
-
其实select返回以后,仅仅会返回有事件就绪的文件描述符(具体看下图),然后使用
FD_ISSET
方法(传入fd的值和集合的地址),以此从头遍历到末尾后,就会知道哪个文件描述符上有数据。 -
出错返回负值
-
readfds
,writefds
,exceptfds
:
作用1:监听的文件描述符的集合,分别是 :
读事件的文件描述符集合;(以连接套接字为例:如果接收缓冲区中有数据,读事件就是就绪的)
写事件的文件描述符集合;(发送缓冲区有空间就就绪,一般一开始就是就绪状态)
异常事件的文件描述符集合.
作用2:内核监听到某些文件描述符有事件发生时,也是通过这三个参数告知应用程序的。 ——> 在线修改,因此每次调用select之前必须重新设置这三个fd_set
timeout
:定时时间,在这段时间内监听所有关注的文件描述符,如果定时时间到了,依旧没有事件就绪,select也会返回。如果需要select永久阻塞,则将timeout置为NULL;
select的集合fd_set
typedef long int _fd_mask;
#define _NFDBITS (8 * (int)sizeof(_fd_mask))
typedef struct
{
_fd_mask _fds_bits[32];
#define _FDS_BITS(set) ((set)->_fds_bits)
}fd_set;
fd_set记录文件描述符的方式:按位记录,来节省空间。
(eg:要记录的文件描述符是3,只需要1左移三位再进行或运算即可)
由于我们按照上述fdset
的方式存放文件描述符,不太方便,所以还给我提供了以下的一些操作函数:
操作fd_set
的宏函数:
#include <sys/select.h>
FD_SET(int fd, fd_set *fdset)
//设置fdset中的位fd, 即将fd加入到fdset
FD_CLR(int fd, fd_set *fdset)
//清除fdset的位fd
int FD_ISSET(int fd, fd_set *fdset)
//测试fdset的位fd是否被设置。
FD_ZERO(int *fdset)
//清除fdset中的所有位
timeval结构体如下:
struct tmieval
{
long tv_sec; //秒数
long tv_usec; //微秒数
};
小案例:select检测键盘是否有数据
select实现TCP服务器:并发处理客户端请求
客户端除了有自己的sockfd之外,在客户端请求之后,accept后也会产生新的文件描述符,所以服务器端的文件描述符会随着客户端的请求的增多而增多,随着连接的断开,服务端的文件描述符就减少了,所以服务器端的文件描述符一直是动态变化的。
我们的处理思路就是:
我们将所有的文件描述符收集起来存放在一个数组中,先将sockfd放入到集合中,检测集合中的文件描述符是否有事件发生,一旦有事件发生,accept后将新的文件描述符c放到集合中,接下来对集合中的sockfd进行accept操作,对c进行recv操作,如此循环,集合中的文件描述符的数量不断增加,由此用单个线程处理客户端的请求。
注意
:对于监听套接字sockfd我们执行accept,对于链接套接字我们执行recv操作
服务器端
// ser.c
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h>
#define FDNUMBER 1024 //文件描述符数组的大小
// 根据ip地址与端口号创建套接字,返回创建的套接字的文件描述符
int CreateSocket(char *ip, short port)
{
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if(listenfd == -1) return -1;
struct sockaddr_in ser_addr;
memset(&ser_addr, 0, sizeof(ser_addr));
ser_addr.sin_family = AF_INET;
ser_addr.sin_port = htons(port);
ser_addr.sin_addr.s_addr = inet_addr(ip);
int res = bind(listenfd, (struct sockaddr*)&ser_addr, sizeof(ser_addr));
if(res == -1) return -1;
res = listen(listenfd, 5);
if(res == -1) return -1;
return listenfd;
}
// 将所有的文件描述符设置到fd_set结构体变量上, 找到当前最大的文件描述符的值
int InitFdSet(int *all_fd, fd_set *set)
{
FD_ZERO(set);
int maxfd = -1;
int i = 0;
for(; i < FDNUMBER; ++i)
{
if(all_fd[i] != -1)
{
FD_SET(all_fd[i], set);
if(all_fd[i] > maxfd)
{
maxfd = all_fd[i];
}
}
}
return maxfd;
}
// 初始化文件描述符数组
void InitAllFd(int *all_fd)
{
int i = 0;
for(; i < FDNUMBER; ++i)
{
all_fd[i] = -1;
}
}
// 向all_fd数组中插入fd
void InsertFd(int *all_fd, int fd)
{
int i = 0;
for(; i < FDNUMBER; ++i)
{
if(all_fd[i] == -1)
{
all_fd[i] = fd;
return;
}
}
}
// 根据val在all_fd中进行删除,flag为1,则val为文件描述符, flag是0,则val是数组的下标
void DeleteFd(int *all_fd, int val, int flag)
{
if(flag)
{
int i = 0;
for(; i < FDNUMBER; ++i)
{
if(all_fd[i] == val)
{
all_fd[i] = -1;
return;
}
}
}
else
{
all_fd[val] = -1;
}
}
// 处理就绪的事件
void DealReadyEvent(int *all_fd, fd_set *set, int listenfd)
{
// 对所有就绪事件的处理还依旧是串行的
int i = 0;
for(; i < FDNUMBER; ++i)
{
if(all_fd[i] == -1) continue;
if(FD_ISSET(all_fd[i], set)) // 如果返回为真,说明这个文件描述符在set中,也就是文件描述符事件就绪
{
/*
tcp服务器程序的所有文件描述符可以分成两类: 监听客户端链接的文件描述符、 与一个客户端链接的文件描述符
如果是监听文件描述符就绪,处理就是accept
如果是链接文件描述符,处理就是recv/send
*/
if(all_fd[i] == listenfd)
{
struct sockaddr_in cli_addr;
socklen_t addr_len = sizeof(cli_addr);
int c = accept(listenfd, (struct sockaddr*)&cli_addr, &addr_len);
if(c < 0) continue;
// 将接收客户端链接的文件描述符添加到all_fd数组中
InsertFd(all_fd, c);
printf("Get New Clinet Link\n");
}
else
{
char buff[128] = {0};
int n = recv(all_fd[i], buff, 127, 0);
if(n <= 0) // recv失败,或者客户端关闭了链接
{
close(all_fd[i]);
printf("%d Over\n", all_fd[i]);
DeleteFd(all_fd, i, 0);
}
else
{
printf("%d: %s\n", all_fd[i], buff);
send(all_fd[i], "OK", 2, 0);
}
}
}
}
printf("DealReadyEvent over\n");
}
int main()
{
// 创建套接字
int listenfd = CreateSocket("192.168.133.132", 6000);
assert(listenfd != -1);
//记录当前程序所打开的所有的文件描述符
int all_fd[FDNUMBER];
InitAllFd(all_fd);
InsertFd(all_fd, listenfd);
while(1)
{
/*
select的三个参数:最大文件描述符值+1,关注读、写、异常事件的文件描述符集合fd_set,超时时间
*/
fd_set read_set;
int maxfd = InitFdSet(all_fd, &read_set);
struct timeval timeout = {5, 0};
int n = select(maxfd+1, &read_set, NULL, NULL, NULL);//&timeout);
printf("select return\n");
if(n < 0)
{
printf("select error\n");
continue;
}
if(n == 0)
{
printf("timeout\n");
continue;
}
DealReadyEvent(all_fd, &read_set, listenfd);
}
}
客户端
// cli.c
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h> //字节序的转换
#include <arpa/inet.h> //IP地址转换
int main()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0);//创建套接字
assert(-1 != sockfd);
struct sockaddr_in ser_addr;
memset(&ser_addr, 0, sizeof(ser_addr));
ser_addr.sin_family = AF_INET;
ser_addr.sin_port = htons(6000);
ser_addr.sin_addr.s_addr = inet_addr("192.168.133.132");
int res = connect(sockfd, (struct sockaddr*)&ser_addr, sizeof(ser_addr));//指定连接的服务器端的 IP 地址和端口
assert(-1 != res);
while(1)
{
printf("input: ");
char buff[128] = {0};
fgets(buff, 127, stdin);
if(strncmp(buff, "end", 3) == 0)
{
break;
}
send(sockfd, buff, strlen(buff) - 1, 0);
memset(buff, 0, 128);
recv(sockfd, buff, 127, 0);
printf("%s\n", buff);
}
close(sockfd);
exit(0);
}
select的相关总结
- 最多只能监听1024个文件描述符,而且文件描述符的值最大为1023
- 只能关注3种事件类型: 读事件、写事件、异常事件
- 内核会在线修改用户传递关注事件的文件描述符的集合的变量(修改fd_set结构), 每次调用select之前都必须重新设置三个fd_set结构变量。
- select返回后,只是告诉用户程序有几个文件描述符就绪,但是并没有指定是哪几个文件描述符。用户程序就需要遍历所有的文件描述符,在探测哪些文件描述符就绪,所以时间复杂度为O(n)。
- 用户程序需要自己维护所有的文件描述符,每次调用select的时候,都需要将用户空间的fd_set集合传递给内核空间,select返回时,又需要将内核的fd_set传递给用户空间。这样调用select的时候,会存在两次数据的拷贝,效率不是很高。
- select内核采用的是轮询的方式去监测哪些文件描述符上的事件就绪。
- select的工作模式只能是LT模式。