1. C/S 网络编程模型

C/S 模型在我们日常生活中随处可见,比如网上购物,游戏,聊天等等软件,用的都是 C/S 模型。C/S 模型比较简单,它的一般流程如下图所示:

image-20250624140831553

  1. 当一个客户端需要某个服务时,它会像对应的服务器发送一个请求。请求的格式是事先双方约定好的,以保证服务器可以解析这个请求。

  2. 服务器接收到请求后,会解析并处理这个请求。

  3. 服务器处理完请求后,会给客户端发送一个响应。响应可以是处理后的结果,也可以是指引客户端下一步操作的指示。

  4. 客户端接收到响应后,会解析响应并处理响应(当然客户端也可能什么都不做)。

服务器端是我们要关注的重点。它需要事先监听在一个众所周知的端口上,然后等待客户端的请求。一旦有客户端连接,服务器端就要消耗一定的计算机资源为它服务。服务器端是需要同时为成千上万的客户端服务的,因此,保证服务器端在海量的客户端访问时依然能保持稳定和高效,就至关重要。

客户端相对来说简单许多,它向服务器的监听端口发起连接请求。连接建立之后,他就可以和服务器端进行通信了。

2. socket()

客户端和服务器端要进行通信,第一件要做的事情就是双方创建通信端点。那怎么创建通信端点呢?答案是socket(),它会创建一个通信端点,并返回一个指向该端点的文件描述符。

1
2
3
4
5
6
#include <sys/types.h>          
#include <sys/socket.h>

int socket(int domain, int type, int protocol);

// Returns: a file descriptor if OK; -1 on error and errno is set.

参数

domain: 是用来指定协议族的。AF_INET表示 IPv4 互联网协议;AF_INET6表示 IPv6 互联网协议;AF_UNIXAF_LOCAL用来本地通信的。

type: 套接字的类型。SOCK_STREAM表示字节流套接字,SOCK_DGRAM表示数据报套接字。

protocol: 设计之初原本是用来指定通信协议的,但现在基本没用,因为现在前面两个参数domaintype就可以唯一确定一个协议。protocol目前填 0 即可。

Example

如果你记不住这些参数也不碍事,我们只需要把调用getaddrinfo()的结果,传递给socket()就可以了。就像下面这样:

1
2
3
4
5
6
7
8
9
struct addrinfo hints, *result;

// [pretend we already filled out the `hints` struct]
// do the lookup
getaddrinfo("www.baidu.com", "http", &hints, &result);

// You should walk the `result` linked list looking for valid entries,
// instead of just assuming the first one is good (like the example do).
int sock_fd = socket(result->ai_family, result->ai_socktype, result->ai_protocol);

The old-school way 以前我们是这样调用socket()函数的:int sock_fd = socket(AF_INET, SOCK_STREAM, 0)。这样做有一个很大的弊端,就是它是硬编码的。我们经常不知道我们写的程序会部署在怎样的环境下。

3. bind()

服务器需要监听在一个众所周知的端口,因此,我们需要给刚刚创建的通信端点绑定一个众所周知的地址。bind()函数就是用来做这件事情的。

1
2
3
4
5
6
#include <sys/types.h>
#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);

// Returns: 0 if Ok; -1 on failure, and `errno` is set.

参数

sockfd: 关联 “socket 文件”(通信端点)的文件描述符。

addr: 要绑定的套接字地址。

addrlen: 套接字地址的长度。

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct addrinfo hints, *result;
// fill out hints
bzero(&hints, sizeof(hints));
hints.ai_family = AF_UNSPEC; // use IPv4 or IPv6, whichever
hints.ai_socktype = SOCK_STREAM; // TCP only
hints.ai_flags = AI_PASSIVE; // wildcard address

// When used with wildcard address, `node` must be NULL.
// Otherwise `AI_PASSIVE` flag is ignored.
getaddrinfo(NULL, "9527", &hints, &result);

int sock_fd = socket(result->ai_family, result->ai_socktype, result->ai_protocol);

// bind a address to `sock_fd`
bind(sock_fd, result->ai_addr, result->ai_addrlen);

通配符地址(wildcard address) 服务器端一般用得都是通配符地址,这样服务器就可以接收来自各个网卡的连接。在老的代码中,指定通配符地址要麻烦许多。 IPv4: addr.sin_addr.s_addr = htonl(INADDR_ANY); IPv6: addr.sin6_addr = in6addr_any; 感谢getaddrinfo(),让我们省去了这些麻烦!

注意,不要绑定 1024 以下的端口。这些端口是系统保留端口,需要超级用户权限才能使用。

另一个要注意的事项是,bind()函数可能会失败,并返回 “Address already in use” 错误。出现这个错误的原因有多个,其中一个常见的原因就是上一次断开的连接还处于TIME_WAIT状态。如果你想在TIME_WAIT状态也可以重用原来的端口,那么需要设置套接字的SO_REUSEADDR选项。在 C 语言中,我们是这样做的:

1
2
3
int opt_value = 1;
// Reuse port number even though connection in `TIME_WAIT` status.
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt_value, sizeof(opt_value));

4. listen()

socket()函数创建的套接字,是一个主动套接字,它可以主动发送请求,也可以和另一个主动套接字传输数据。

listen()函数可以将原来的主动套接字转换为被动套接字,被动套接字是用来等待用户请求的。操作系统会为被动套接字创建一些用来接收用户请求的数据结构,比如半连接队列和已连接队列。

1
2
3
4
5
6
#include <sys/types.h>
#include <sys/socket.h>

int listen(int sockfd, int backlog);

// Returns: 0 if Ok; -1 on failure, and `errno` is set.

参数

sockfd: 主动套接字的文件描述符。

backlog: 在 Linux 中,backlog表示已连接队列的最大大小。

5. accept()

accept()函数会从已连接队列中接收一条连接,并为其创建一个 socket 文件用于通信。

1
2
3
4
5
6
7
#include <sys/types.h>
#include <sys/socket.h>

int accept(int listenfd, struct sockaddr* addr, socklen_t* addrlen);

// Returns: a file descriptor for accepted socket if Ok;
// -1 on failure and `errno` is set.

参数

listenfd: 被动套接字的文件描述符,用于监听连接的。

addr: 用于接收远端的地址。通常addr会指向一个struct sockaddr_storage结构体。

addrlen: 这是一个传入传出参数,传入的时候,它会告诉内核addr的实际长度,避免缓冲区溢出。传出的时候,它会告诉应用程序远端地址的实际长度:IPv4 为 16,IPv6 为 28。

Example

至此,服务器端主要函数就介绍完了。我们一起来看一下服务器端的主要流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 省略了错误处理
// server.c
#include "lib/common.h"

#define PORT "9527"
#define BACKLOG 10

int main(int argc, char* argv[])
{
struct addrinfo hints, *result;
// fill out `hints`
bzero(&hints, sizeof(hints));
hints.ai_family = AF_UNSPEC; // use IPv4 or IPv6, whichever
hints.ai_socktype = SOCK_STREAM; // TCP only
hints.ai_flags = AI_PASSIVE; // wildcard address

getaddrinfo(NULL, PORT, &hints, &result);

// make a socket, bind it, and listen on it.
int listen_fd = socket(result->ai_family, result->ai_socktype, result->ai_protocol);
bind(listen_fd, result->ai_addr, result->ai_addrlen);
listen(listen_fd, BACKLOG);

// now, ready to accept an incoming connection.
struct sockaddr_storage client_addr;
socklen_t addrlen = sizeof(client_addr);
int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addrlen);

// ready to communicate on socket descriptor `conn_fd`
.
.
.
}

6. connect()

上面描述的是服务器端的流程,客户端的流程要简单许多。

第一步和服务器端一样,调用socket()创建一个套接字。第二步,客户端需要调用connect()向服务器端发起连接请求。

1
2
3
4
5
6
#include <sys/types.h>
#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);

// Returns: 0 if Ok; -1 on failure and `errno` is set.

参数

sockfd: 客户端套接字。

addr: 服务器端的地址。

addrlen: 服务器端地址的长度。

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 为了教学的目的,省略了错误处理
// client.c
#include "lib/common.h"

#define SERV_PORT "9527"

int main(int argc, char* argv[])
{
struct addrinfo hints, *result;
// fill out `hints`
bzero(&hints, sizeof(hints));
hints.ai_family = AF_UNSPEC; // use IPv4 or IPv6, whichever
hints.ai_socktype = SOCK_STREAM; // TCP only
// get the address of server
getaddrinfo("127.0.0.1", SERV_PORT, &hints, &result);

// make a socket
int sock_fd = socket(result->ai_family, result->ai_socktype, result->ai_protocol);

// initiate a connection to server
connect(sock_fd, result->ai_addr, result->ai_addrlen);

// ready to communicate on socket descriptor `sock_fd`
.
.
.
}

注意,客户端调用connect()之前,可以但没有必要去调用bind()。因为内核会自动确定 IP 地址,并选择一个可用的端口。

如果是SOCK_STREAM类型的套接字,调用connect()将激发 TCP 的三次握手过程,且仅在连接建立成功或出错时才会返回。

6.1 TCP 的三次握手

接下来,一起来看一下著名的 TCP 三次握手过程。

image-20250624142138266

注意,默认情况下 socket 网络编程都是阻塞式的,当然也有非阻塞式的,我们在后面的章节会讲。所谓阻塞式就是应用程序发起函数调用后不会直接返回,由操作系统内核处理完成之后才会返回。

解读

服务器端调用socket()bind()listen()完成了被动套接字的准备工作,所谓”被动“就是等待客户端来连接。然后调用accept()去接收已建立的连接,如果没有已建立的连接,accept()就会陷入阻塞。

客户端调用socket()完成主动套接字的准备工作。然后调用connect()主动发起连接请求,这时候就会触发 TCP 的三次握手过程,connect()陷入阻塞。

接下来的事情就由操作系统内核的网络协议栈完成,具体过程如下:

  1. 客户端的网络协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号为 j,客户端进入SYNC_SENT状态。
  2. 服务器端的网络协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 j+1,表示对 SYN 包 j 的确认;同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 k,服务器端进入SYN_RCVD状态。
  3. 客户端网络协议栈收到 ACK 之后,就会让应用程序从connect()调用返回。客户端到服务器端的单向连接也就建立成功了,客户端进入ESTABLISHED状态。同时客户端网络协议栈也会对服务器端的 SYN 包进行应答,应答数据为 k+1。
  4. 应答包到达服务器端后,服务器端网络协议栈会让accept()调用返回。这时服务器端到客户端的单向连接也建立成功,服务器端也进入ESTABLISHED状态。

归纳总结

这一章,我们主要讲了服务器端和客户端的建立连接的主要流程,并且结合 socket 网络编程详细讲解了 TCP 的三次握手。

  • 服务器端通过socket()bind()listen()完成被动套接字的准备工作;通过accept()函数接收已建立的连接。

  • 客户端通过socket()完成主动套接字的准备工作;通过connect()主动发起连接请求。

参考文献