TCP/IP网络编程:TCP服务器的基础搭建以及使用io复用优化构建

一、TCP服务器基础搭建思路

        本文主要介绍Linux端采用实现简单的接受数据的TCP服务端实现。

        开发环境:Ubuntu 16.04.6 

        文中代码及网络调试器均以上传至github,链接见文末

1.1 使用多线程基础实现

        TCP client端的操作主要有这两个请求:连接请求和传输数据请求。因此我们可以设计两部分套接字,第一部分为单独的一个套接字socket,句柄为sockfd,对服务器主机指定的端口号上收到的连接请求进行监听(listen)。一旦发现有来自其他ip的连接请求,服务器端执行accept,在第二部分套接字中创建一个client_fd套接字与客户端创立连接,并检测连接内的输入。

        可以想象,服务器就是一个高端餐厅,服务员分为两种,一种为在大门口的迎宾员,一种为专门为你服务,带你认路、点菜的具体“引路”员。某人A来到餐厅门口,即某客户端对服务器发起连接请求,迎宾员(socket)会捕捉到这一请求,并马上告诉餐厅(服务器),安排一个“引路”员专门点对点为其服务(accept创建一个client_fd套接字专门与该客户端进行连接)。关于socket编程的基础知识,推荐参阅书本TCP/IP网络编程(尹圣雨著)。

        总结:构建TCP服务器核心三个步骤:

        1.构建socket服务员,用来listen固定端口上的连接请求

        2.有连接请求:accept,创建套接字与客户端连接

        3.有可读管道:recv,并实现输出

        以下是一个基础的采用多线程实现的TCP服务器:

//使用多线程实现
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#include <pthread.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>

#include <errno.h>

#define MAX_LISTEN_NUM 10   //最大同时listen数量
#define BUFFER_SIZE 1024    //读取缓冲大小

void *Client_Call_Back(void* arg){    //客户线程响应函数
        
    int clientfd=*(int *)arg;
    //取得客户端句柄

    while(1) //处理第三步:有可读管道时,将数据读入buffer缓冲区,再实现输出打印printf
             //注意:客户端主动关闭连接,也是一个可读事件,进行读取时recv会返回0
    {
        char buffer[1024]={0};
        int len=recv(clientfd,buffer,BUFFER_SIZE,0);
        if(len==0){             //说明客户端主动断开了连接
            close(clientfd);    //关闭分配给该客户端的套接字
            break;
        }

        printf("clientfd=%d len=%d buffer=%s\n",clientfd,len,buffer);//进行输出
    }

    return (void*)0;
}

int main(int argc,char* argv[]){
                                    //传入参数为指定的端口号

    if(argc<2)
    {
        printf("need port!\n");
        return -1;
    }
    
    //step1 创建sockfd服务员 用来监听
    int sockfd=socket(PF_INET,SOCK_STREAM,0);  //创建套接字 用来listen端口连接请求

    struct sockaddr_in addr;                   //addr是分配给sockfd地址
    memset(&addr,0,sizeof(struct sockaddr_in));

    addr.sin_family=AF_INET;
    addr.sin_addr.s_addr=htonl(INADDR_ANY);    //0.0.0.0 允许接受任意ip的连接请求
    addr.sin_port=htons(atoi(argv[1]));        //设置输入的端口号

    int ret=bind(sockfd,(struct sockaddr*)&addr,sizeof(struct sockaddr));
    //将sockkfd设置好地址
    if(ret==-1){
        perror("bind");
        return -2;
    }

    ret=listen(sockfd,MAX_LISTEN_NUM);           //让sockfd开始listen
    if(ret==-1){
        perror("listen");
        return -3;
    }
    //step1结束,sockfd设置完毕

    //初始化客户端地址和句柄,为accept做准备
    struct sockaddr_in client_addr;                    //client_addr为客户端地址
    memset(&client_addr,0,sizeof(client_addr));        
    int client_len=sizeof(client_addr);                //client_len为对应长度
    int clientfd=0;                                    //clientfd为当前要处理的客户端对应的套接字的文件描述符

    //服务器开始运行
    while(1){
        

        //step2开始,处理新连接请求
        clientfd=accept(sockfd,(struct sockaddr*)&client_addr,&client_len);//接受新的连接申请
        if(clientfd == -1)
        {
            perror("accept");
            return -4;
        }
        
        //为新连接创建新线程
        pthread_t threadid;            
        pthread_create(&threadid,NULL,Client_Call_Back,&clientfd);  //让线程开始工作,将客
户端对应套接字句柄作为参数传入
    }

    close(sockfd);
    return 0;
}
1.2 服务器测试

        使用NetAssit0进行测试

        在linux输入以下编译命令并运行:

gcc -o tcp tcp_server_pthread.c -lpthread
./tcp 8888

        使用NetAssit0,选择TCP client栏,输入服务器ip及端口并连接,发送数据如下所示:

可以看到服务器实现了对两个客户端进行的连接。如果您的NetAssit0无法连接,尝试关闭服务器的ipv6服务或者尝试禁用一下防火墙:sudo ufw disable

二、io复用

        可以看到,使用多线程或者多进程完全可以实现服务器的简单功能。但是,“一请求一连接”的工作方式存在很大的缺陷。一旦客户端的连接数量变多,创建线程和进程的开销会变得非常大,对服务器性能影响很大。因此人们想到了io复用方法。什么是复用呢?通常来说,就是以最小的物理开销,实现传递最多信息的功能。io复用的目的就是让服务器能抛开多线程/进程,以单进程单线程的方式管理连接。省去不必要的创建线/进程的开销。

        io具体如何实现的呢?在尹书中有一个形象的例子:在一所学校中有一个班,班里有10个学生。一班的学生特别爱举手提问题,学校无奈,只能给每个学生配备一个专门的老师进行辅导解答。可是这样对学校来说非常不划算,因为老师的费用开销极大,十位老师在学校里也十分地占位置。于是学校聘请了一位超能力老师,他不知疲倦,并且可以以非常快的速度回答每个学生的问题,问题间可以没有时间间隔,堪比并行,要的薪资还和以往一位老师一样多。这样一位老师的占位置(空间开销)很小,招聘费用(创建线程/进程所花的运算和内存)也小。

        而这个超能力老师采用以下的管理方法:班上的学生要发言必须举手,该老师会先确认有无学生举手,如果没有他则在这一时刻休息,等待有人举手;一旦有人举手,他就会去确认是谁举手,并为他瞬间解决问题。也就是说,服务器端(老师)会同一管理包括sockfd和所有clientfd在内的一切套接字(学生),来检测他们有没有可读数据(是否举手)。如果没有管道里有可读数据,也就是说客户端没有人有发送数据的请求,即没有学生提问,那么服务器会不进行处理,等待数据的到来;而一旦有管道有了输入数据,变为可读状态(有学生举手要问问题),服务器会立马捕捉,并立即处理该套接字的事项。服务器会区分sockfd和clientfd,当sockfd可读时,说明有新的连接请求,则要进行accept创建新的clientfd;当clientfd可读时,说明有数据输入,则读入数据。可以认为sockfd是一个班长,隔壁班看到了老师很眼馋想加入班级。一旦有新的加入班级请求,班长就会举手,老师为他处理他的问题,即放新的同学加入班级,并给他分配一个编号(创建新的clientfd)。

三、基于io复用构建TCP服务器

3.1 select和poll

        根据以上说法,我们可以得出构建io复用TCP服务器的几个小步骤:

                step1:创建sockfd用于listen

                step2:维护一个数据结构D,该结构内管理所有的套接字(包括sockfd和clientfd,初始为空,初始化时应该加入sockfd),并且可以根据该结构查询指定套接字是否可读。定义一个方法Fun,调用Fun可以使得进程和操作系统交互,更新指定套接字是否可读的状态。

                step3:每次检查所有套接字是否可读,如果可读,区分该套接字类型:如果是sockfd,说明有新的连接请求,则调用accept,创建新的clientfd与客户端连接,并将clientfd加入数据结构D中进行监视;如果是clientfd,说明有新的传输数据请求,则调用recv接受数据。

                流程图示意如下:

很幸运,select和poll给我们提供了现成的数据结构以及api来维护。我们只需学会使用即可。

3.1.1 select

        select采用fd_set类型数组代表D。fd_set是一个1024位的数组,代表1024个文件描述符的集合。每一位只置0和1,第i位代表文件描述符为i的套接字的状态,如果为1,代表该文件描述符具有某种状态,如果为0,代表该文件描述符没有某种状态。该状态可以自己设定。

        select提供以下宏来便捷进行维护:

FD_ZERO(fd_set* fdset): 将fd_set变量的所有位初始化为0。
FD_SET(int fd, fd_set* fdset):在参数fd_set指向的变量中注册文件描述符fd的信息。
FD_CLR(int fd, fd_set* fdset):参数fd_set指向的变量中清除文件描述符fd的信息。
FD_ISSET(int fd, fd_set* fdset):若参数fd_set指向的变量中包含文件描述符fd的信息,则返回真。

        select提供select函数来与内核交互,更新文件操作符的状态。以下是select函数定义:

#include <sys/select.h>
#include <sys/time.h>
int select(int maxfd, fd_set* readset, fd_set* writeset, fd_set* exceptset, 
const struct timeval* timeout);> 

        maxfd代表最大文件标识符编号,select内部会采用for循环,遍历0~maxfd的文件描述符进行更新。2~5位表示设定的状态,参数2为可读集合,参数3为可写集合,参数4为出错集合。最后一个函数为超时时间,设为NULL表示永久不超时,阻塞,设为0表示不等待,设为其他正数表示设定固定的等待时间,超时就会返回。 例如:

select(20,&readset,NULL,NULL,NULL);

     表示对readset这个集合中的0~20文件标识符进行更新,更新关注的是可不可读状态。select返回后,在readset集合0~20文件描述符中,如果为1则代表该位文件描述符所代表的套接字具有可读状态。一般的,传入的readset应该是一个准备好的一个集合,该集合置1代表的性质为该文件描述符正在被监视。

        以前文学生老师的比喻作为解释:该教室现在扩大,有着1024个座位。并且一开始只有班长(socket)。老师手里有一份关注表(rfbs),上面记载了哪些位置是需要关注的,也就是说哪些位置上是有学生的(哪些套接字是正在被监听的)。老师(服务器)有一个秘书(select函数),每次老师会给秘书一份关注表的副本(rfbs_copy)。秘书会根据关注表副本,判断每一个被关注的位置上是否有学生举手(clientfd是否可读)。老师会记得一个数字(maxfd),每次告诉秘书,查到这个位置就不用继续查了。然后秘书会告诉老师,在0~maxfd这些座位间,有那个座位上是有学生而且举手了。秘书会根据传入的关注表副本,返回一个二进制的数组集合(rfds_copy),第i位代表第i个座位的状态,以及告诉老师总共有多少人举手了(nready)。

        老师会先判断班长有没有举手,如果班长举手(select可读),说明有新的学生要加入班级,那么老师调用accept,为新学生分配座位(clientfd),在关注表中加入这个座位,并且把maxfd更新,保证下一次秘书进行检查时能够囊括进新学生。然后老师再依次看剩下的数组集合,对举手的同学进行处理。

如果举手的同学说的是他要离开班级(recv==0),那么老师会放他走,并且从关注表中删除这个座位。

        select实现代码如下:

//使用select实现
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#include <sys/socket.h>
#include <sys/select.h>
#include <unistd.h>
#include <netinet/in.h>

#include <errno.h>

#define MAX_LISTEN_NUM 10
#define BUFFER_SIZE 1024

int main(int argc,char* argv[]){
    if(argc<2){
        printf("need port!\n");
        return -1;
    }
    
//step1 创建sockfd 用于listen
    int sockfd=socket(PF_INET,SOCK_STREAM,0);
    
    struct sockaddr_in addr;
    memset(&addr,0,sizeof(addr));
    addr.sin_family=AF_INET;
    addr.sin_addr.s_addr=htonl(INADDR_ANY);
    addr.sin_port=htons(atoi(argv[1]));

    int res=bind(sockfd,(struct sockaddr*)&addr,sizeof(struct sockaddr));
    if(res==-1){
        perror("bind");
        return -2;
    }

    res=listen(sockfd,MAX_LISTEN_NUM);
    if(res==-1){
        perror("listen");
        return -3;
    }
//step1结束

//初始化client_addr,clientfd,client_len,便于accept操作,放在循环内accept前也可以
    struct sockaddr_in client_addr;
    memset(&client_addr,0,sizeof(struct sockaddr));
    int client_len=sizeof(client_addr);
    int clientfd=0;
//

//初始化数据结构D,此处为fd_set。rfds用来存储被监视的套接字的集合,rfds_copy用于每次进行select
    fd_set rfds,rfds_copy;
    FD_ZERO(&rfds);
    FD_SET(sockfd,&rfds);    //初始化,把sockfd加入
    int maxfd=sockfd;        //保证第一次遍历能遍历到sockfd

    while(1){
        rfds_copy=rfds;    //拷贝副本
        
        //进行对0~maxfd+1的文件描述符的状态更新,状态关注的是是否可读,返回可读集合为rfds_copy 并且进行阻塞等待

        int nready=select(maxfd+1,&rfds_copy,NULL,NULL,NULL);
    
        //此处将sockfd是否可读拿出来特判,其实按照流程图加到下文中的循环里也可以。
        if(FD_ISSET(sockfd,&rfds_copy)){ //如果sockfd可读,说明有新的连接请求
            clientfd=accept(sockfd,(struct sockaddr*)&client_addr,&client_len);//创建新的clientfd
            if(clientfd==-1){
                perror("accept");
                return -4;
            }

            FD_SET(clientfd,&rfds);            //维护D,将新创建的clientfd加入监视集合中
            if(clientfd>maxfd)maxfd=clientfd;    //更新maxfd 保证一切clientfd都能被遍历到
            if(nready==1)continue;            //nready==1说明只有当前socket可读,则下面的循环可以跳过,此步可加可不加 无伤大雅
        }

//遍历select返回的可读集合
        for(int i=sockfd+1;i<=maxfd;i++){
            if(FD_ISSET(i,&rfds_copy)){
                char buffer[BUFFER_SIZE]={0};
                int len=recv(i,buffer,BUFFER_SIZE,0);    //进行recv
                if(len==0){  //说明客户端主动关闭连接 那么关闭当前套接字,并取消监视
                    FD_CLR(i,&rfds);
                    close(i);
                    break;
                }
                printf("clientfd:%d len:%d buffer:%s\n",i,len,buffer);
            }
        }

    }
    
    close(sockfd);
    return 0;
}
        3.1.2 poll

        select有一个很大的确定是fd_set是定长的1024。不能自己设置长度。而poll可以解决这个问题

        poll提供类型struct pollfd,fd代表文件描述符,events是服务器设定的要监视的时间,POLLIN表示监视可读时间,具体其他参数可以参考书本或者其他博客。revents是内核返回给应用层的该文件的事件。

struct pollfd {
int fd; /* 文件描述符 */
short events; /* 监控的事件 */
short revents; /* 监控事件中满足条件返回的事件 */
};

        poll提供poll函数,和select功能一样,更新指定范围的pollfd的状态。fds是创好的pollfd数组的头指针,nfds类似与select中的maxfd,表示循环到哪结束。timeout也是等待时间,-1表示阻塞,0表示不等待,正数表示固定等待时间。

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

创建一个pollfd数组 等价于创建fd_set   select用位表示状态,poll中用一个结构体表示。select中rfds表示要监视的文件,并由rfds_copy带回状态;poll中用events设定事件表示正在监视,revents表示从内核带回的状态。代码如下:

//poll
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#include <sys/socket.h>
#include <sys/poll.h>
#include <unistd.h>
#include <netinet/in.h>

#include <errno.h>

#define MAX_LISTEN_NUM 10
#define BUFFER_SIZE 1024
#define POLL_SIZE 1024

int main(int argc,char* argv[]){

    if(argc<2){
        printf("need port!");
        return -1;
    }

    int sockfd=socket(PF_INET,SOCK_STREAM,0);

    struct sockaddr_in addr={0};
    addr.sin_family=AF_INET;
    addr.sin_addr.s_addr=htonl(INADDR_ANY);
    addr.sin_port=htons(atoi(argv[1]));

    int ret=bind(sockfd,(struct sockaddr*)&addr,sizeof(struct sockaddr));
    if(ret == -1){
        perror("bind");
        return -2;
    }

    ret=listen(sockfd,MAX_LISTEN_NUM);
    if(ret == -1){
        perror("listen");
        return -3;
    }

    struct sockaddr client_addr={0};
    int client_len=sizeof(struct sockaddr);
    int clientfd=0;

    struct pollfd fds[POLL_SIZE]={0};
    fds[sockfd].fd=sockfd;            //初始化加入sockfd
    fds[sockfd].events=POLLIN;        //设定监视事件
    int maxfd=sockfd;

    while(1){

        int nready=poll(fds,maxfd+1,-1);  //等价于select

        if(nready==-1)
        {
            perror("poll");
            return -4;
        }

        if(fds[sockfd].revents & POLLIN){    //如果sockfd是可读的
            clientfd=accept(sockfd,(struct sockaddr*)&client_addr,&client_len);
            if(clientfd==-1)
            {
                perror("accpet");
                return -5;
            }

            fds[clientfd].fd=clientfd;
            fds[clientfd].events=POLLIN;

            if(clientfd>maxfd)maxfd=clientfd;
            if(nready==1)continue;
        }

        for(int i=sockfd+1;i<=maxfd;i++){
            if(fds[i].revents & POLLIN){        //如果可读
                char buffer[BUFFER_SIZE]={0};
                int len=recv(i,buffer,BUFFER_SIZE,0);
                if(len==0){    //如果连接断开
                    fds[i].events=-1;
                    fds[i].fd=-1;
                    close(i);
                    break;
                }
                printf("clientfd:%d len:%d buffer:%s\n",clientfd,len,buffer);

            }

        }
    }

    close(sockfd);
    return 0;
}
3.2 epoll

        select和poll都实现了io复用,使用单进程单线程进行服务。但是由于底层实现仍然为for循环。一旦客户端数量一大,每次循环就会遍历很多无用的套接字。  如果每次可以只返回可读的集合,就可以减少遍历开销,减少与操作系统的交互。

        代码如下:

//使用epoll(水平触发)
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <unistd.h>

#include <errno.h>

#define MAX_LISTEN_NUM 10
#define EVENTS_NUM 1024
#define BUFFER_SIZE 1024

int main(int argc,char* argv[]){
    if(argc<2){
        printf("need port!");
        return -1;
    }

    int sockfd=socket(PF_INET,SOCK_STREAM,0);
    
    struct sockaddr_in addr={0};
    addr.sin_family=AF_INET;
    addr.sin_addr.s_addr=htonl(INADDR_ANY);
    addr.sin_port=htons(atoi(argv[1]));

    int ret=bind(sockfd,(struct sockaddr*)&addr,sizeof(struct sockaddr));
    if(ret==-1){
        perror("bind");
        return -1;
    }

    ret=listen(sockfd,MAX_LISTEN_NUM);
    if(ret==-1){
        perror("listen");
        return -2;
    }

    struct sockaddr_in client_addr={0};
    int client_len=sizeof(struct sockaddr_in);
    int clientfd=0;

    int epfd=epoll_create(1);
    if(epfd==-1){
        perror("epoll_create failed");
        return -3;
    }

    struct epoll_event sock_listen_ev;
    sock_listen_ev.events=EPOLLIN;
    sock_listen_ev.data.fd=sockfd;
    epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,&sock_listen_ev);

    struct epoll_event events[EVENTS_NUM];

    while(1){
        int nready=epoll_wait(epfd,events,EVENTS_NUM,-1);
        
        if(nready==-1){
            perror("epoll_wait");
            return -4;
        }

        for(int i=0;i<=nready;i++){
            int curfd=events[i].data.fd;
            if(curfd==sockfd){
                clientfd=accept(sockfd,(struct sockaddr*)&client_addr,&client_len);
                if(clientfd==-1){
                    perror("accept");
                    return -5;
                }

                struct epoll_event client_ev;
                client_ev.data.fd=clientfd;
                client_ev.events=EPOLLIN;
                epoll_ctl(epfd,EPOLL_CTL_ADD,clientfd,&client_ev);
            } else if (events[i].events == EPOLLIN){
                char buffer[BUFFER_SIZE]={0};
                int len=recv(curfd,buffer,BUFFER_SIZE,0);
                if(len==0){
                    epoll_ctl(epfd,EPOLL_CTL_DEL,curfd,NULL);
                    close(curfd);
                    break;
                }
                printf("clientfd:%d len=%d buffer=%s\n",curfd,len,buffer);
            }
        }
    }
    close(sockfd);
    return 0;
}

        epoll提供struct epoll_event事件结构如下:

struct epoll_event {     
__uint32_t events; // Epoll events   
epoll_data_t data; // User data variable
};
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
 

 使用只需了解:events表示监视的事件,EPOLLIN表示监视输入。data是一个八字节内存空间,你想用来存啥都行。存一个指针,存一个int fd都可以。由于epoll__wait()函数与内核操作的返回是从黑盒中返回的,所以你不能直接知道触发了这个event的套接字是那个,所以event内置data,方便从操作系统返回时能定位到该事件的相应数据。

        epoll提供以下函数:
        

int epoll_create(int size); //用来创建一个epoll ,参数size现在没有意义,只需传入任意正整数,效果都一样;返回epoll的句柄

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);//维护epoll,用来注册、删除事件等

int epoll_wait(int epid, struct epoll_event *events, int maxevents, int timeout);//等价于select和poll 从内核带回新状态

具体可以参考博客epoll函数原理和使用介绍_椛茶的博客-CSDN博客

本文中采用水平触发方式。建议更多了解水平触发和边沿触发的区别,更好地掌握epoll

四、总结

io复用进行tcp服务端的构建框架思路其实非常非常相似,只需掌握各个模块给的api就能非常简单的使用。select/poll适用于小型,epoll适用于大型服务器。第一次写博客可能有很多不足希望大家体谅。如果有错,欢迎和我交流。

点赞关注安安喵谢谢喵

Github代码地址:Zzzzzya/Multi-inet-io: 使用C语言,基于/select/poll/epoll的io复用的TCP服务端 (github.com)

NetAssit:Zzzzzya/NetAssit: 网络测试器 (github.com)

课程学习链接:https://xxetb.xet.tech/s/4czPSo

  • 12
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值