用C编写一个简单服务器
前言
本文使用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函数仅有服务器调用,它完成两件事情:
- 当使用socket函数创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将发送connect发起连接的客户端套接字。当调用listen函数之后,它被转成一个被动套接字,只是内核应该接受连接请求。所以,调用listen之后套接字由CLOSED状态转到LISTEN状态
- 这个函数规定内核应该为相应套接字排队的最大连接数
函数原型
/*失败时返回-1*/
int listen(int sockfd, int backlog)
backlog参数的设定其实是表示两个队列的总和,这两个队列分别是
- 未完成连接队列,在客户端发送一个SYN直到三次握手完成,都是这个状态,SYN_RCVD状态。
- 已完成连接队列,这个表示三次握手完成的状态,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函数我们先不实现,我们来看一下一个并发服务器处理一个客户端连接的流程
- 服务器阻塞于accept调用且来自客户的连接请求到达时的客户端与服务器的状态
- 从accept返回后,连接已经在内核中注册,并且新的套接口connfd被创建。这是一个已建起连接的套接口,可以进行数据的读写。
3.并发服务器在调用fork之后,listenfd和connfd这两个描述字在父进程以及子进程之间共享(实际为其中一份为copy)
image- 接下来是由父进程关闭已连接套接口(connfd),由子进程关闭监听套接口(listenfd)。然后由子进程负责为客户端提供服务
最终我们的并发服务器代码为
#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是同时输出来的。这种服务器就可以做到快速同时处理多个客户端连接。