使用select函数改进客户端/服务器端程序

当我们使用单进程单连接且使用readline修改后的客户端程序,去连接使用readline修改后的服务器端程序。服务器主动close后,客户端和服务器会出现一个有趣的现象。

先运行服务器端,再运行客户端。建立连接后,服务器端有两个进程,一个父进程处于监听状态,另一子进程正在对客户端进行服务。kill掉服务器端的子进程。

分析问题

查看一下网络状态,发现客户端处于CLOSE_WAIT状态,服务器的状态为FIN_WAIT2。

simba@ubuntu:~$ netstat -an | grep tcp | grep 5188
tcp        0      0 0.0.0.0:5188            0.0.0.0:*               LISTEN     
tcp        1      0 127.0.0.1:54005         127.0.0.1:5188          CLOSE_WAIT 
tcp        0      0 127.0.0.1:5188          127.0.0.1:54005         FIN_WAIT2

分析一下,将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那里,即从标准输入读取数据,所以不能执行到下面的readline,也即不能返回0,不会退出循环,不会调用close关闭sock,所以出现上述的情况,即状态停滞,不能向前推进。

select改进客户端

出现上述问题的根本原因在于客户端程序不能并发处理从标准输入读取数据和从套接字读取数据两个事件,我们可以使用前面讲过的select函数来完善客户端程序。

void do_echocli(int sock)
{
    fd_set rset;
    FD_ZERO(&rset);

    int nready;
    int maxfd;
    int fd_stdin = fileno(stdin); //fileno用来取得参数stream指定的文件流所使用的文件描述符
    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函数修改后的程序,可以看到最后的输出。

mur@ubuntu:~$ netstat -an | grep tcp | grep 5188
tcp        0      0 0.0.0.0:5188            0.0.0.0:*               LISTEN     
tcp        0      0 127.0.0.1:5188          127.0.0.1:54007         TIME_WAIT

client关闭socket描述符,server子进程的TCP连接收到client发的FIN段后处于TIME_WAIT状态,此时会再发生一个ACK段给client,client接收到之后就处于CLOSED状态。TCP协议规定,主动关闭连接的一方要处于TIME_WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态,需要有MSL时间的主要原因是在这段时间内如果最后一个ack段没有发送给对方,则可以重新发送。

select改进服务端

使用select函数来改进服务器端程序,实现单进程并发服务。

#include<stdio.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<unistd.h>
#include<stdlib.h>
#include<errno.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<string.h>
#include<signal.h>
#include<sys/wait.h>
#include "read_write.h"

#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(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0)
//  listenfd = socket(AF_INET, SOCK_STREAM, 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];
    int maxi = 0; // 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;
        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)
                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 = readline(conn, recvbuf, 1024);
                if (ret == -1)
                    ERR_EXIT("readline error");
                else if (ret  == 0) { //客户端关闭 
                    printf("client close \n");
                    FD_CLR(conn, &allset); // 踢出该端口
                    client[i] = -1;
                    close(conn);
                }

                fputs(recvbuf, stdout);
                writen(conn, recvbuf, strlen(recvbuf));

                if (--nready <= 0)
                    break; 
            }
        }
    }

    return 0;
}

程序第一次进入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。