美文网首页技术随笔我的Coding
用C编写一个简单服务器

用C编写一个简单服务器

作者: 蛋炒饭先生007 | 来源:发表于2017-04-23 11:58 被阅读3026次

    前言

    本文使用C语言编写一个简单服务器,旨在更好的理解服务端/客户端程序,迭代服务器,并发服务器等概念,仅供学习参考。这篇文章的例子很简单,就是当客户端连接上服务端之后,服务端给出一个“Hello World”回应。

    C/S结构流程图

    整个客户端,服务端交互流程可以用下图表示,服务端是优先启动进程并监听某一个端口,并且进程一直阻塞,直到有客户端连接进来,才开始处理客户端连接。

    image

    服务端

    通过流程图可以看出,服务端涉及的Socket函数有socket, bind, listen, accept, read, write, close。使用这7个函数就可以编写出一个简易服务器。

    socket函数

    为了执行网络I/O,一个进程必须做的第一件事情就是创建一个socket函数,函数原型

    # family 表示协议族
    # type 表示套接字类型
    # protocol 表示传输协议
    # 若成功返回非负描述符,若出错返回-1
    int socket(int family, int type, int protocol);
    

    这个函数需要传入协议族,套接字类型,传输层协议三个参数。

    协议族可以有以下取值

    family 说明
    AF_INET IPv4协议
    AF_INET6 IPv6协议
    AF_LOCAL Unix域协议
    AF_ROUTE 路由套接字
    AF_KEY 密钥套接字

    套接字类型可以有以下取值

    type 说明
    SOCK_STREAM 字节流套接字
    SOCK_DGRAM 数据报套接字
    SOCK_SEQPACKET 有序分组套接字
    SOCK_ROW 原始套接字

    传输层协议可以有以下取值

    protocol 说明
    IPPROTO_TCP TCP传输协议
    IPPROTO_UDP UDP传输协议
    IPPROTO_SCTP SCTP传输协议

    这里我们选择IPv4协议,使用字节流套接字,传输层选择TCP协议,所以第一段代码:

    #include <stdio.h>
    #include <sys/socket.h>
    int main()
    {
        int server_sockfd = socket(AF_INET,SOCK_STREAM, IPPROTO_TCP);
        
        if(server_sockfd == -1){
            printf("socket error");
            return -1;
        }
    }
    

    bind函数

    bind函数把一个本地协议地址赋予一个套接字,对于网际协议,协议地址就是IP加端口的组合,函数原型

    # sockfd 初始化的套接字
    # myaddr 协议地址
    # addrlen 协议地址长度
    # 若成功返回0 出错返回-1
    int bind(int sockfd, const struct sockaddr * myaddr, socklen_t addrlen)
    

    注意,这个函数不是必须的,如果不使用这个函数绑定一个特定的端口,那么内核会帮我们的套接字选择一个临时端口。作为服务器,一般不会这么做,需要指定特定的端口。

    这个函数的第二个参数是协议地址,注意,这个协议地址已经有定义好的结构体,使用IPv4套接字结构地址时候,地址结构体定义如下

    struct sockaddr_in {
        uint8_t sin_len; /*结构体长度*/
        sa_family_t sin_family; /*AF_INET*/
        in_port_t sin_port; /*端口(16-bie)*/
        struct in_addr sin_addr; /*IPv4地址(32-bit)*/
        char sin_zero[8]; /*没啥用,设置0即可*/
    }
    

    我们让我们的服务器绑定8887端口(80端口被web占用了,用8887端口代替),所以我们的第二段代码

    #include <stdio.h>
    #include <netinet/in.h>
    #include <sys/socket.h>
    int main()
    {
        int server_sockfd = socket(AF_INET,SOCK_STREAM, IPPROTO_TCP);
        
        if(server_sockfd == -1){
            printf("socket error");
            return -1;
        }
        
        struct sockaddr_in server_sockaddr;/*声明一个变量,类型为协议地址类型*/
        server_sockaddr.sin_family = AF_INET;/*使用IPv4协议*/
        server_sockaddr.sin_port = htons(8887);/*监听8887端口*/
        server_sockaddr.sin_addr.s_addr = htonl(INADDR_ANY);/*绑定本机IP,使用宏定义绑定*/
    
        if(bind(server_sockfd,(struct sockaddr *)&server_sockaddr,sizeof(server_sockaddr))==-1){
            printf("bind error");
            return -1;
        }
    }
    

    listen函数

    listen函数仅有服务器调用,它完成两件事情:

    1. 当使用socket函数创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将发送connect发起连接的客户端套接字。当调用listen函数之后,它被转成一个被动套接字,只是内核应该接受连接请求。所以,调用listen之后套接字由CLOSED状态转到LISTEN状态
    2. 这个函数规定内核应该为相应套接字排队的最大连接数

    函数原型

    /*失败时返回-1*/
    int listen(int sockfd, int backlog)
    

    backlog参数的设定其实是表示两个队列的总和,这两个队列分别是

    1. 未完成连接队列,在客户端发送一个SYN直到三次握手完成,都是这个状态,SYN_RCVD状态。
    2. 已完成连接队列,这个表示三次握手完成的状态,ESTABLISHED状态

    因为我们是测试,这个值设置成20就可以了。所以我们的第三段代码

    #include <stdio.h>
    #include <netinet/in.h>
    #include <sys/socket.h>
    int main()
    {
        int server_sockfd = socket(AF_INET,SOCK_STREAM, IPPROTO_TCP);
        
        if(server_sockfd == -1){
            printf("socket error");
            return -1;
        }
        
        struct sockaddr_in server_sockaddr;/*声明一个变量,类型为协议地址类型*/
        server_sockaddr.sin_family = AF_INET;/*使用IPv4协议*/
        server_sockaddr.sin_port = htons(8887);/*监听8887端口*/
        server_sockaddr.sin_addr.s_addr = htonl(INADDR_ANY);/*绑定本机IP,使用宏定义绑定*/
    
        if(bind(server_sockfd,(struct sockaddr *)&server_sockaddr,sizeof(server_sockaddr))==-1){
            printf("bind error");
            return -1;
        }
        
        if(listen(server_sockfd, 20) == -1){
            printf("listen error");
            return -1;
        }
    
    }
    

    accept函数

    accept函数是由TCP服务器调用,用于从已完成连接队列的队头返回下一个已完成连接,如果已完成连接队列为空,那么进程进入睡眠模式,函数原型

    # sockdf 服务器套接字莫描述符
    # cliaddr 已连接的客户端协议地址
    # addrlen 已连接的客户端协议地址长度
    # 成功返回非负描述符,出错返回-1
    int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
    

    当accept成功时,返回值是由内核自动生成的全新描述符,代表与所返回的客户端TCP连接。所以,在我们讨论accept函数时,我们称第一个参数为监听套接字,它的返回值是已连接套接字,一个服务器通常指创建一个监听套接字(通常是80端口),内核为每个由服务器进程接受的客户端连接创建一个已连接套接字,当服务器完成对某个给定的客户端服务时,连接就会被关闭。

    函数的第二个参数也是一个协议地址结构体,这个结构体和服务端协议地址是同一个结构体。我们可以不关心客户端的协议,直接传空,我们关系的是这个函数的返回值,因为它返回的是客户端连接描述符,我们可以对这个描述符进行写操作,从而实现给客户端传输数据。所以我们第四段代码

    #include <stdio.h>
    #include <netinet/in.h>
    #include <sys/socket.h>
    int main()
    {
        int server_sockfd = socket(AF_INET,SOCK_STREAM, IPPROTO_TCP);
        
        if(server_sockfd == -1){
            printf("socket error");
            return -1;
        }
        
        struct sockaddr_in server_sockaddr;/*声明一个变量,类型为协议地址类型*/
        server_sockaddr.sin_family = AF_INET;/*使用IPv4协议*/
        server_sockaddr.sin_port = htons(8887);/*监听8887端口*/
        server_sockaddr.sin_addr.s_addr = htonl(INADDR_ANY);/*绑定本机IP,使用宏定义绑定*/
        
        if(bind(server_sockfd,(struct sockaddr *)&server_sockaddr,sizeof(server_sockaddr))==-1){
            printf("bind error");
            exit(1);
        }
        
        if(listen(server_sockfd, 20) == -1){
            printf("listen error");
            exit(1);
        }
        
        struct sockaddr_in clnt_addr;/*只是声明,并没有赋值*/
        socklen_t clnt_addr_size = sizeof(clnt_addr);
        int clnt_sock = accept(server_sockfd, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
        
        if(clnt_sock == -1){
            printf("appect error");
            return -1;
        }
        
    }
    

    write函数

    前面的操作都完成之后,说明服务端和客户端已经建立连接,由于TCP的传输是全双工的,这时候客户端和服务端都可以向对方发送数据。这里为了简化,我们实现服务端发送“Hello World”给请求连接的客户端。给客户端发送数据很简单,就是对返回的客户端描述符进行写操作就可以了

    # sockfd socket文件描述符
    # buf 文件内容
    # count 内容长度
    ssize_t write(int sockfd, const void * buf, size_t count);
    

    完整的服务器代码

    #include <stdio.h>
    #include <netinet/in.h>
    #include <sys/socket.h>
    int main()
    {
        int server_sockfd = socket(AF_INET,SOCK_STREAM, IPPROTO_TCP);
        
        if(server_sockfd == -1){
            printf("socket error");
            return -1;
        }
        
        struct sockaddr_in server_sockaddr;/*声明一个变量,类型为协议地址类型*/
        server_sockaddr.sin_family = AF_INET;/*使用IPv4协议*/
        server_sockaddr.sin_port = htons(8887);/*监听8887端口*/
        server_sockaddr.sin_addr.s_addr = htonl(INADDR_ANY);/*绑定本机IP,使用宏定义绑定*/
        
        if(bind(server_sockfd,(struct sockaddr *)&server_sockaddr,sizeof(server_sockaddr))==-1){
            printf("bind error");
            return -1;
        }
        
        if(listen(server_sockfd, 20) == -1){
            printf("listen error");
            return -1;
        }
        
        struct sockaddr_in clnt_addr;/*只是声明,并没有赋值*/
        socklen_t clnt_addr_size = sizeof(clnt_addr);
        int clnt_sock = accept(server_sockfd, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
        
        if(clnt_sock == -1){
            printf("appect error");
            return -1;
        }
        
        char str[] = "Hello World";
        write(clnt_sock, str, sizeof(str));
        
        close(clnt_sock);
        close(server_sockfd);
    }
    

    客户端

    客户端要和服务器进行通信,从流程图上可以看出,需要使用socket, connect, write, read, close这5个函数

    socket函数

    客户端要和服务端进行网络通讯,首先也必须调用socket函数,这里客户端也使用IPv4协议,使用字节流套接字,传输层选择TCP协议,所以第一段代码

    #include <stdio.h>
    #include <sys/socket.h>
    int main()
    {
        int sock_cli = socket(AF_INET,SOCK_STREAM, IPPROTO_TCP);
        
        if(sock_cli == -1){
            printf("socket error");
            return -1;
        }
    }
    

    connect函数

    TCP客户端就是使用connect函数和服务端建立连接,函数原型

    # sockfd 客户端TCP描述符
    # sockaddr 服务端协议地址
    # addrlen 服务端协议地址长度
    int connect(int sockfd, const struct sockaddr * servaddr, socklen_t addrlen);
    

    这个函数将触发客户端和服务端三次握手,函数的第一个参数sockfd表示客户端返回的描述符,这里不需要调用bind函数绑定端口,系统会自动分配。函数的第二个参数需要配置服务端IP和端口信息,同样有结构体规范这些信息,结构体也是和服务端一样使用sockaddr_in类型。所以第二段代码

    #include <stdio.h>
    #include <sys/socket.h>
    int main()
    {
        int sock_cli = socket(AF_INET,SOCK_STREAM, IPPROTO_TCP);
        
        if(sock_cli == -1){
            printf("socket error");
            return -1;
        }
        
        struct sockaddr_in servaddr;
        servaddr.sin_family = AF_INET;/*使用IPv4协议*/
        servaddr.sin_port = htons(8887);/*需要连接的远程服务器端口*/
        servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");/*需要连接的远程服务器IP*/
        
        if(connect(sock_cli, (struct sockaddr *)&servaddr, sizeof(servaddr))  == -1){
            printf("connect error");
            return -1;
        }
    
    }
    

    read函数

    客户端连接上服务器之后返回的是一个Socket文件描述符,既然是文件描述符,就可以通过简单的read函数获取网络数据,read函数原型

    # sockdf 文件描述符
    # buf 文件内容存放地址
    # count 内容长度
    ssize_t read(int sockfd,void *buf,size_t count)
    

    这里我们读取64个字节就够了,不需要太多

    char str[64];
    read(sock_cli, str, 64);
    

    完整的客户端代码

    #include <stdio.h>
    #include <netinet/in.h>
    #include <sys/socket.h>
    int main()
    {
        int sock_cli = socket(AF_INET,SOCK_STREAM, IPPROTO_TCP);
        
        if(sock_cli == -1){
            printf("socket error");
            return -1;
        }
        
        struct sockaddr_in servaddr;
        servaddr.sin_family = AF_INET;/*使用IPv4协议*/
        servaddr.sin_port = htons(8887);/*需要连接的远程服务器端口*/
        servaddr.sin_addr.s_addr = inet_addr("127.0.0.1");/*需要连接的远程服务器IP*/
        
        if(connect(sock_cli, (struct sockaddr *)&servaddr, sizeof(servaddr))  == -1){
            printf("connect error");
            return -1;
        }
    
        char str[64];
        read(sock_cli, str, 64);
        printf(str);
    
        close(sock_cli);
    }
    

    运行客户端服务端

    将我们的服务端代码保存为server.c,将我们的客户端代码保存为client.c。分别编译客户端和服务端代码

    [root@iZ940ofmvruZ socket]# gcc server.c -o server
    [root@iZ940ofmvruZ socket]# gcc client.c -o client
    

    然后会分别生成两个可执行文件server和client。在一个窗口中先执行server

    [root@iZ940ofmvruZ socket]# ./server
    
    
    

    执行server之后,我们知道accept函数会阻塞,所以程序一直运行,等待客户端连接进来。这时候在另一个窗口执行客户端

    [root@iZ940ofmvruZ socket]# ./client 
    Hello World
    

    可以看到服务端给我们发送的Hello World,我们再回到服务端执行窗口时,服务端也终止了进程,这个交互完成。然后我们会发现一个问题,服务端在提供完服务之后,它自己也关闭了,很显然,我们希望服务器是一致运行的提供服务,所以我们需要实现一直运行的服务器

    不间断提供服务

    让服务器一直运行的方式很简单,就是死循环。循环的过程是accept一个客户端连接,然后处理数据请求,最后关闭客户端连接。注意,我们不能关闭服务端连接。所以我们改进这个程序,让server不间断的调用accept,因为accept总是从已连接的队列中返回一个连接,然后处理。改进内容片断

    /**
     * 进入死循环调用accept,给每一个连接上来的客户端发送Hello World
     */
    for( ; ; ){
        struct sockaddr_in clnt_addr;/*只是声明,并没有赋值*/
        socklen_t clnt_addr_size = sizeof(clnt_addr);
        int clnt_sock = accept(server_sockfd, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
    
        if(clnt_sock == -1){
            printf("appect error");
            return -1;
        }
    
        char str[] = "Hello World";
        write(clnt_sock, str, sizeof(str));
    
        close(clnt_sock);
        /*close(server_sockfd);*/
    }
    
    

    这样修改之后,这个服务端程序就是一直不间断提供服务了

    并发服务器

    迭代服务器

    我们首先来看下什么是迭代服务器,因为我们刚才所写的就是一个迭代服务器,思考一个问题,假如我们的服务器不是输出Hello World这么简单,而是需要经过一系列复杂逻辑计算甚至网络调用,那我们的程序执行起来就不会怎么快了,为了模拟这种场景,我们在服务端程序中假如sleep函数,我们让程序睡眠3秒钟,模拟服务器处理复杂逻辑时间

    #include <stdio.h>
    #include <unistd.h>
    #include <netinet/in.h>
    #include <sys/socket.h>
    int main()
    {
        int server_sockfd = socket(AF_INET,SOCK_STREAM, IPPROTO_TCP);
        
        if(server_sockfd == -1){
            printf("socket error");
            return -1;
        }
        
        struct sockaddr_in server_sockaddr;/*声明一个变量,类型为协议地址类型*/
        server_sockaddr.sin_family = AF_INET;/*使用IPv4协议*/
        server_sockaddr.sin_port = htons(8887);/*监听8887端口*/
        server_sockaddr.sin_addr.s_addr = htonl(INADDR_ANY);/*绑定本机IP,使用宏定义绑定*/
        
        if(bind(server_sockfd,(struct sockaddr *)&server_sockaddr,sizeof(server_sockaddr))==-1){
            printf("bind error");
            return -1;
        }
        
        if(listen(server_sockfd, 20) == -1){
            printf("listen error");
            return -1;
        }
    
        for( ; ; ){    
            struct sockaddr_in clnt_addr;/*只是声明,并没有赋值*/
            socklen_t clnt_addr_size = sizeof(clnt_addr);
            int clnt_sock = accept(server_sockfd, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
            
            if(clnt_sock == -1){
                printf("appect error");
                return -1;
            }
            
            char str[] = "Hello World";
            sleep(3);//3秒之后再向客户端发送数据
            write(clnt_sock, str, sizeof(str));
        
            close(clnt_sock);
            /*close(server_sockfd);*/
        }
    }
    

    运行这个服务端程序之后,我们同时执行10个客户端

    for(( i=0; i< 10; i++ ))
        do
        {
            ./client
        }&
    done
    

    在shell中执行这段代码,你会发现每隔3秒钟输出一个Hello World。这是因为我们的服务端程序是阻塞的,在处理一个请求的同时,其他请求只能等。所以最后一个客户端连接需要等到30秒才能收到服务端的输出。我们称这种服务器为迭代服务器,迭代服务器会依次处理客户端的连接,只要当前连接的任务没有完成,服务器的进程就会一直被占用,直到任务完成后,服务器关闭这个socket,释放连接。这显然不是我们想要的,我们希望每一个Hello Wrold都在3秒钟后马上输出。

    并发服务器

    当一个服务处理客户端请求需要花费较长时间,但是我们又不希望整个服务器被单个客户端长期占用,而是希望同时服务多个客户。Unix中编写并发服务器最简单的办法就是fork一个子进程来服务每个客户。利用fork函数可以把处理客户端请求的任务交接到子进程,这样就实现多进程并发,我们可以写出这样服务器的轮廓

    pid_t pid;
    for( ; ; ){    
        struct sockaddr_in clnt_addr;/*只是声明,并没有赋值*/
        socklen_t clnt_addr_size = sizeof(clnt_addr);
        int clnt_sock = accept(server_sockfd, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
        
        if(clnt_sock == -1){
            printf("appect error");
            return -1;
        }
        
        /**
         * 这一段直接fork一个子进程
         * 子进程处理单独处理完请求之后退出
         */
        if( (pid = fork()) == 0 ){
            close(server_sockfd);/*子进程不需要监听,关闭*/
            doit(clnt_sock);/*针对已连接的客户端套接字进行读写*/
            close(clnt_sock);/*处理完毕,关闭客户端连接*/
            exit(0);/*自觉退出*/
        }
        
        close(clnt_sock); /*连接已经交由子进程处理,父进程可以关闭客户端连接了*/
        /*close(server_sockfd);*/
    }
    
    

    其中,doit函数我们先不实现,我们来看一下一个并发服务器处理一个客户端连接的流程

    1. 服务器阻塞于accept调用且来自客户的连接请求到达时的客户端与服务器的状态
    image
    1. 从accept返回后,连接已经在内核中注册,并且新的套接口connfd被创建。这是一个已建起连接的套接口,可以进行数据的读写。
    image

    3.并发服务器在调用fork之后,listenfd和connfd这两个描述字在父进程以及子进程之间共享(实际为其中一份为copy)

    image
    1. 接下来是由父进程关闭已连接套接口(connfd),由子进程关闭监听套接口(listenfd)。然后由子进程负责为客户端提供服务
    image

    最终我们的并发服务器代码为

    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    #include <netinet/in.h>
    #include <sys/socket.h>
    
    void doit(int sockfd);
    
    int main()
    {
        int server_sockfd = socket(AF_INET,SOCK_STREAM, IPPROTO_TCP);
        pid_t pid;
        
        if(server_sockfd == -1){
            printf("socket error");
            return -1;
        }
        
        struct sockaddr_in server_sockaddr;/*声明一个变量,类型为协议地址类型*/
        server_sockaddr.sin_family = AF_INET;/*使用IPv4协议*/
        server_sockaddr.sin_port = htons(8887);/*监听8887端口*/
        server_sockaddr.sin_addr.s_addr = htonl(INADDR_ANY);/*绑定本机IP,使用宏定义绑定*/
        
        if(bind(server_sockfd,(struct sockaddr *)&server_sockaddr,sizeof(server_sockaddr))==-1){
            printf("bind error");
            return -1;
        }
        
        if(listen(server_sockfd, 20) == -1){
            printf("listen error");
            return -1;
        }
    
        for( ; ; ){    
            struct sockaddr_in clnt_addr;/*只是声明,并没有赋值*/
            socklen_t clnt_addr_size = sizeof(clnt_addr);
            int clnt_sock = accept(server_sockfd, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
        
            if(clnt_sock == -1){
                printf("appect error");
                return -1;
            }
    
            if( (pid = fork()) == 0 ){
                close(server_sockfd);/*子进程不需要监听,关闭*/
                doit(clnt_sock);/*针对已连接的客户端套接字进行读写*/
                close(clnt_sock);/*处理完毕,关闭客户端连接*/
                exit(0);/*自觉退出*/
            }    
    
            close(clnt_sock);
            /*close(server_sockfd);*/
        }
    }
    
    void doit(int sockfd){
        char str[] = "Hello World";
        sleep(3);//3秒之后再向客户端发送数据
        write(sockfd, str, sizeof(str));
    }
    
    

    这个时候再次利用shell并行执行我们的客户端,就会发现,所有的Hello World是同时输出来的。这种服务器就可以做到快速同时处理多个客户端连接。

    相关文章

      网友评论

      • MR丿VINCENT:请问一下unix c socket中读和写数据到网络中不仅仅有read和write函数,是不是也可以使用recv和send来达到同样的目的呢?看完这些底层的操作对网络通信理解更透彻一点了,谢谢你的笔耕分享。
      • Anomaly:用Python,PHP,Java都能实现,而且更简单点
        蛋炒饭先生007:@Anomaly 也可以,但是用c的话了解的细节更多一点
      • 繁星若尘啊::blush: 现在不都流行epoll这些多路I/O复用技术吗
        蛋炒饭先生007:@寻风233 是的,这是一个很简单入门例子,还有很多待处理,比如信号处理,复用IO模型后续文章更新
      • 北京大数据苏焕之:你有这微信吗?我也爱好写文章!能不能加个好友,没有别的意思就是平常交流交流!😁😁😁如果有冒犯请谅解!喜欢你的文章想认识你一下
      • 0897aa9d4d2a:写的挺详细的,希望继续坚持,先关注了

      本文标题:用C编写一个简单服务器

      本文链接:https://www.haomeiwen.com/subject/gzaezttx.html