基于 TCP 的服务器端和客户端
在上一篇中了解了协议族、套接字类型、TCP 的传输特性、地址信息有哪些、地址信息如何表示、如何初始化以及如何绑定到套接字上进行理解。
此篇会对流程中的后几个流程进行理解,以及实现 TCP 服务器端和客户端的迭代代码程序。
等待连接请求状态和受理请求
将套接字绑定号分配的地址之后,需要调用 listen
函数进入等待连接请求的状态。只有调用了 listen
函数,客户端才能进入可发出连接请求的状态,也就是客户端才能调用 connect
函数。 客户端发送连接请求后,客户端就会进入等候队列。这些都是由服务器端的套接字来完成,简单的理解:服务器端套接字相当于 HR,客户端相当于求职者,调用 listen
函数类似于 HR 发布一个工作招聘岗位,此时 HR 就处于等待简历的状态;调用 connect
相当于求职者发送简历,此时我们就进入等候队列,等待 HR 的通过。
等候队列的大小是由 listen
函数的第二个参数来决定,一但发送连接请求的客户端超过此队列大小,服务器端就会自动结束。就如同,一旦 HR 接收的求职者超过他的预期,可能就会关闭招聘岗位。
#include <sys/socket.h>
// sock:希望进入等待连接请求状态的套接字文件描述符,传递的描述符套接字参数称为服务器端套接字
// backlog:连接请求等待队列的长度,若为 5,则队列长度为 5,表示最多使 5 个连接请求进入队列
int listen(int sock, int backlog);
连接请求受理是按序受理,调用 accept
函数后,服务器端会从等候队列中按发送连接请求的先后顺序来受理。受理后需要,意味着服务器端和客户端可以进行数据通信,此时就需要一个新的 fd 与之绑定,通过这个 fd 与对应的客户端进行数据通信。类似于,HR 按序同意求职者的简历,此时就需要一个面试官与求职者进行面试,每一个求职者和面试官是一一对应的(现实中可能不是一个求职者一个面试官,此处忽略),这是和求职者交互的一个通道。
#include <sys/socket.h>
// sock:服务器套接字的文件描述符
// addr:保存发起连接请求的客户端地址信息的变量地址值,调用函数后向传递来的地址变量参数填充客户端地址信息
// addrien:第二个参数 addr 结构体的长度,但是存有长度的变量地址,填充客户端地址长度
int accept(int sock, struct sockaddr *addr, socklen_t *addrlen);
了解完这些流程,TCP 服务器端和客户端之间的调用流程如下所示
在第一篇实现的服务器端和客户端就是基于 TCP 实现,但是此程序只能处理一个客户端程序,连接请求的等待队列就没有实际意义。那么如何将原先的程序扩展到可以按序处理多个客户端 —— 最简单的方式:将受理连接请求和数据处理等操作放置在一个循环中(由于目前的代码都是单进程单线程的,所以同一时刻只能处理一个客户端)。程序的流程如下
迭代服务器端的实现
处理多个客户端时要求服务器端的程序发生改变,客户端不需要变化,服务器端的程序如下(Windows 中实现基本相同,此处不再展现)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
int main(int argc, char *argv[]) {
if (2 != argc) {
fprintf(stderr, "Usage: %s <port>\n", argv[0]);
exit(EXIT_FAILURE);
}
// 1. 创建套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == sockfd) {
perror("socket() error");
exit(EXIT_FAILURE);
}
// 2. 绑定地址信息
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(atoi(argv[1]));
if (-1 == bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr))) {
perror("bind() error");
close(sockfd);
exit(EXIT_FAILURE);
}
// 3. 打开可连接状态,进入监听
if (-1 == listen(sockfd, 5)) {
perror("listen() error");
close(sockfd);
exit(EXIT_FAILURE);
}
struct sockaddr_in clnt_addr;
memset(&clnt_addr, 0, sizeof(clnt_addr));
socklen_t clnt_len = sizeof(clnt_addr);
for (int i = 0; i < 5; ++i) {
int clntfd = accept(sockfd, (struct sockaddr *)&clnt_addr, &clnt_len);
if (-1 == clntfd) {
perror("accept() error");
close(sockfd);
exit(EXIT_FAILURE);
} else {
printf("Connected clinet: %d\n", clntfd);
}
char message[1024] = {0};
int str_len = 0;
while (0 != (str_len = read(clntfd, message, 1024)))
write(clntfd, message, str_len);
close(clntfd);
}
close(sockfd);
return 0;
}
此程序可以实现按序处理多个客户端,在某一时刻只能处理一个,要想处理下一个,必须将当前处理的客户端断开才行。
TCP 原理
首先要理解,TCP 程序中 read
和 write
函数并不是说调用一次就直接发送数据或接收数据的,在这中间是有两个缓冲区的,如下所示
这些缓冲区有几个特性:
- I/O 缓冲在每个 TCP 套接字中单独存在
- I/O 缓冲在创建套接字时自动生成
- 即使关闭套接字也会继续传递输出缓冲中遗留的数据
- 关闭套接字将会丢失输入数据中的数据
现在进一步可以知道,write
函数是在数据移到输出缓冲时,才会返回。关于 TCP 的三次握手和四次挥手此处不做详细说明,请自行查阅理解。