一、当我们使用单进程单连接且使用read的客户端程序,去连接服务器端程序,会出现一个有趣的现象,先来看输出:
先运行服务器端,再运行客户端。
可以先查看一下网络状态。
可以看出建立了连接,服务器端有两个进程,一个父进程处于监听状态,另一子进程正在对客户端进行服务。
再ps 出服务器端的子进程,并kill掉它,
再查看一下网络状态。
我们将server子进程 kill掉,则其终止时,socket描述符会自动关闭并发FIN段给client,client收到FIN后处于CLOSE_WAIT状态,但是client并没有终止,也没有关闭socket描述符,因此不会发FIN 段给 server子进程,因此server 子进程的TCP连接处于FIN_WAIT2状态。
为什么会出现这种情况呢,来看client的部分程序:
void do_echocli(int sock)
{
char sendbuf[1024] = {0};
char recvbuf[1024] = {0};
while (fgets(sendbuf, sizeof(sendbuf), stdin) != NULL)
{
writen(sock, sendbuf, strlen(sendbuf));
int ret = readline(sock, recvbuf, sizeof(recvbuf)); //按行读取
if (ret == -1)
ERR_EXIT("readline error");
else if (ret == 0) //服务器关闭
{
printf("server close\n");
break;
}
fputs(recvbuf, stdout);
memset(sendbuf, 0, sizeof(sendbuf));
memset(recvbuf, 0, sizeof(recvbuf));
}
close(sock);
}
客户端程序阻塞在了fgets 那里,即从标准输入读取数据,所以不能执行到下面的readl,也即不能返回0,不会退出循环,不会调用close关闭sock,所以出现上述的情况,即状态停滞,不能向前推进。
出现上述问题的根本原因在于客户端程序不能并发处理从标准输入读取数据和从套接字读取数据两个事件,我们可以使用前面讲过的select函数来完善客户端程序,如下所示:
void do_echocli(int sock)
{
fd_set rset;
FD_ZERO(&rset);
int nready;
int maxfd;
int fd_stdin = fileno(stdin); //
if (fd_stdin > sock)
maxfd = fd_stdin;
else
maxfd = sock;
char sendbuf[1024] = {0};
char recvbuf[1024] = {0};
while (1)
{
FD_SET(fd_stdin, &rset);
FD_SET(sock, &rset);
nready = select(maxfd + 1, &rset, NULL, NULL, NULL); //select返回表示检测到可读事件
if (nready == -1)
ERR_EXIT("select error");
if (nready == 0)
continue;
if (FD_ISSET(sock, &rset))
{
int ret = readline(sock, recvbuf, sizeof(recvbuf)); //按行读取
if (ret == -1)
ERR_EXIT("read error");
else if (ret == 0) //服务器关闭
{
printf("server close\n");
break;
}
fputs(recvbuf, stdout);
memset(recvbuf, 0, sizeof(recvbuf));
}
if (FD_ISSET(fd_stdin, &rset))
{
if (fgets(sendbuf, sizeof(sendbuf), stdin) == NULL)
break;
writen(sock, sendbuf, strlen(sendbuf));
memset(sendbuf, 0, sizeof(sendbuf));
}
}
close(sock);
}
即将两个事件都添加进可读事件集合,在while循环中,如果select返回说明有事件发生,依次判断是哪些事件发生,如果是标准输入有数据可读,则读取后再次回到循环开头select阻塞等待事件发生,如果是套接口有数据可读,且返回为0则说明对方已经关闭连接,退出循环并调用close关闭sock。
重复前面的实验过程,把客户端换成使用select函数修改后的程序,可以看到最后的输出:
二、前面实现的能够并发服务的服务器端程序是使用fork出多个子进程来实现的,现在学习了select函数,可以用它来改进服务器端程序,实现单进程并发服务。
首先介绍一下select函数:
/* According to POSIX.1-2001 */
#include
/* According to earlier standards */
#include
#include
#include
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数1:读写异常集合中的文件描述符的最大值加1;
参数2:读集合,关心可读事件;
套接口缓冲区有数据可读
对等连接的写一半关闭。即接收到FIN段,读操作将返回0
如果是监听套接口,已完成连接队列不为空时。
套接口上发生了一个错误待处理,错误可以通过getsockopt指定SO_ERROR选项来获取。
参数3:写集合,关心可写事件;
套接口发送缓冲区有空间容纳数据。(连接一旦建立就可写)
对等连接的读一半关闭。即收到RST段之后,再次调用write操作。
套接口上发生了一个错误待处理,错误可以通过getsockopt指定SO_ERROR选项来获取。
参数4:异常集合,关心异常事件;
套接口存在带外数据(TCP头部 URG标志,16位紧急指针字段)
参数5:超时时间结构体
对于参数2,3,4来说,如果不关心对应事件则设置为NULL即可。注意5个参数都是输入输出参数,即select返回时可能对其进行了修改,比如集合被修改以便标记哪些套接口发生了事件,时间结构体的传出参数是剩余的时间,如果设置为NULL表示永不超时。用select管理多个I/O,select阻塞等待,一旦其中的一个或多个I/O检测到我们所感兴趣的事件,select函数返回,返回值为检测到的事件个数,并且返回哪些I/O发送了事件,遍历这些事件,进而处理事件。注意当select阻塞返回后,此时调用accept 接收连接是不会阻塞的,直接返回已连接套接字,可以认为是select 提前阻塞了。但此时调用write 还是可能阻塞的,因为需要写入的空间大小可能缓冲区还不满足。下面是4个可以对集合进行操作的宏:
void FD_CLR(int fd, fd_set *set); // 清除出集合
int FD_ISSET(int fd, fd_set *set); // 判断是否在集合中
void FD_SET(int fd, fd_set *set); // 添加进集合中
void FD_ZERO(fd_set *set); // 将集合清零
先看如下程序,再来解释:
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#define ERR_EXIT(m) \
do { \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
int main(void)
{
signal(SIGPIPE, SIG_IGN);
int listenfd; //被动套接字(文件描述符),即只可以accept, 监听套接字
if ((listenfd = socket(AF_INET,SOCK_STREAM,0)) < 0)
ERR_EXIT("socket error");
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(5188);
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
/* servaddr.sin_addr.s_addr = inet_addr("127.0.0.1"); */
/* inet_aton("127.0.0.1", &servaddr.sin_addr); */
int on = 1;
if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
ERR_EXIT("setsockopt error");
if (bind(listenfd, (struct sockaddr*)&servaddr,sizeof(servaddr)) < 0)
ERR_EXIT("bind error");
if (listen(listenfd, SOMAXCONN) < 0) //listen应在socket和bind之后,而在accept之前
ERR_EXIT("listen error");
struct sockaddr_in peeraddr; //传出参数
socklen_t peerlen = sizeof(peeraddr); //传入传出参数,必须有初始值
int conn; // 已连接套接字(变为主动套接字,即可以主动connect)
int i;
int client[FD_SETSIZE];//client数组用来保存已连接的套接字
int maxi = 0; //这次为了避免每次遍历到FD_SETSIZE,设置maxi,即client数组中最大的已连接套接字
for (i = 0; i < FD_SETSIZE; i++)//初始化已连接套接字数组
client[i] = -1;
int nready;
int maxfd = listenfd;
fd_set rset;
fd_set allset;
FD_ZERO(&rset);
FD_ZERO(&allset);
FD_SET(listenfd, &allset);
while (1) {
rset = allset; /*这里rset和allset是一致的,由于rset是传入传出参数,
*select返回可能会改变rset的值,所以把套接字先保存在allset中,再复制到rset中
*/
nready = select(maxfd + 1, &rset, NULL, NULL, NULL);//返回值为检测到的事件个数
if (nready == -1) {
if (errno == EINTR)
continue;
ERR_EXIT("select error");
}
if (nready == 0)
continue;
if (FD_ISSET(listenfd, &rset)) {
conn = accept(listenfd, (struct sockaddr*)&peeraddr, &peerlen); //accept不再阻塞
if (conn == -1)
ERR_EXIT("accept error");
for (i = 0; i < FD_SETSIZE; i++) {
if (client[i] < 0) {
client[i] = conn;
if (i > maxi)
maxi = i;
break;
}
}
if (i == FD_SETSIZE) {
fprintf(stderr, "too many clients\n");
exit(EXIT_FAILURE);
}
printf("recv connect ip=%s port=%d\n", inet_ntoa(peeraddr.sin_addr),
ntohs(peeraddr.sin_port));
FD_SET(conn, &allset);
if (conn > maxfd)
maxfd = conn;
if (--nready <= 0)//第一次调用select的时候nready为1
continue;
}
for (i = 0; i <= maxi; i++) {
conn = client[i];
if (conn == -1)
continue;
if (FD_ISSET(conn, &rset)) {
char recvbuf[1024] = {0};
int ret = read(conn, recvbuf, 1024);
if (ret == -1)
ERR_EXIT("read error");
else if (ret == 0) { //客户端关闭
printf("client close \n");
FD_CLR(conn, &allset);//客户端若关闭需要把conn从allset中删除
client[i] = -1;//同时删除客户端数组中的conn
close(conn);
}
fputs(recvbuf, stdout);
write(conn, recvbuf, strlen(recvbuf));
if (--nready <= 0)
break;
}
}
}
return 0;
}
/* select所能承受的最大并发数受
* 1.一个进程所能打开的最大文件描述符数,可以通过ulimit -n来调整
* 但一个系统所能打开的最大数也是有限的,跟内存有关,可以通过cat /proc/sys/fs/file-max 查看
* 2.FD_SETSIZE(fd_set)的限制,这个需要重新编译内核
*/
前面调用socket,listen,bind等函数等初始化工作就不说了。程序第一次进入while 循环,只把监听套接字加入关心的事件,select返回说明监听套接字有可读事件,即已完成连接队列不为空,这时调用accept不会阻塞,返回一个已连接套接字,将这个套接字加入allset,因为第一次运行则nready = 1,直接continue跳回到while 循环开头,再次调用select,这次会关心监听套接字和一个已连接套接字的可读事件,如果继续有客户端连接上来则继续将其加入allset,这次nready = 2,继续执行下面的for 循环,然后对客户端进行服务。服务完毕再次回到while 开头调用select 阻塞时,就关心一个监听套接字和2个已连接套接字的可读事件了,一直循环下去。
程序大概逻辑就这样,一些细节就大家自己想想了,比如client数组是用来保存已连接套接字的,为了避免每次都得遍历到FD_SETSIZE-1,保存一个最大不空闲下标maxi,每次遍历到maxi就可以了。每次得到一个conn,要判断一下conn与maxfd的大小。
当得知某个客户端关闭,则需要将conn在allset中清除掉。之所以要有allset 和 rset 两个变量是因为rset是传入传出参数,在select返回时rset可能被改变,故需要每次在回到while 循环开头时需要将allset 重新赋予rset 。