技术随笔我的Coding

用C编写一个简单服务器

2017-04-23  本文已影响3026人  蛋炒饭先生007

前言

本文使用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是同时输出来的。这种服务器就可以做到快速同时处理多个客户端连接。

上一篇下一篇

猜你喜欢

热点阅读