type
status
date
slug
summary
tags
category
icon
password
激动人心的时刻终于要开始了!这一章,我们将学习 TCP 网络编程!要知道,这个世界上绝大多数网络服务,传输层协议用得可都是 TCP 哦!
不过在具体编写程序之前,我们先来讲一讲 client-server 网络编程模型(C/S模型)。
1. C/S 网络编程模型
C/S 模型在我们日常生活中随处可见,比如网上购物,游戏,聊天等等软件,用的都是 C/S 模型。C/S 模型比较简单,它的一般流程如下图所示:

- 当一个客户端需要某个服务时,它会像对应的服务器发送一个请求。请求的格式是事先双方约定好的,以保证服务器可以解析这个请求。
- 服务器接收到请求后,会解析并处理这个请求。
- 服务器处理完请求后,会给客户端发送一个响应。响应可以是处理后的结果,也可以是指引客户端下一步操作的指示。
- 客户端接收到响应后,会解析响应并处理响应(当然客户端也可能什么都不做)。
服务器端是我们要关注的重点。它需要事先监听在一个众所周知的端口上,然后等待客户端的请求。一旦有客户端连接,服务器端就要消耗一定的计算机资源为它服务。服务器端是需要同时为成千上万的客户端服务的,因此,保证服务器端在海量的客户端访问时依然能保持稳定和高效,就至关重要。
客户端相对来说简单许多,它向服务器的监听端口发起连接请求。连接建立之后,他就可以和服务器端进行通信了。
2. socket()
客户端和服务器端要进行通信,第一件要做的事情就是双方创建通信端点。那怎么创建通信端点呢?答案是
socket()
,它会创建一个通信端点,并返回一个指向该端点的文件描述符。参数
domain
: 是用来指定协议族的。AF_INET
表示 IPv4 互联网协议;AF_INET6
表示 IPv6 互联网协议;AF_UNIX
和AF_LOCAL
用来本地通信的。type
: 套接字的类型。SOCK_STREAM
表示字节流套接字,SOCK_DGRAM
表示数据报套接字。protocol
: 设计之初原本是用来指定通信协议的,但现在基本没用,因为现在前面两个参数domain
和type
就可以唯一确定一个协议。protocol
目前填 0 即可。Example
如果你记不住这些参数也不碍事,我们只需要把调用
getaddrinfo()
的结果,传递给socket()
就可以了。就像下面这样:The old-school way
以前我们是这样调用
socket()
函数的:int sock_fd = socket(AF_INET, SOCK_STREAM, 0)
。这样做有一个很大的弊端,就是它是硬编码的。我们经常不知道我们写的程序会部署在怎样的环境下。3. bind()
服务器需要监听在一个众所周知的端口,因此,我们需要给刚刚创建的通信端点绑定一个众所周知的地址。
bind()
函数就是用来做这件事情的。参数
sockfd
: 关联 “socket 文件”(通信端点)的文件描述符。addr
: 要绑定的套接字地址。addrlen
: 套接字地址的长度。Example
通配符地址(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 语言中,我们是这样做的:4. listen()
socket()
函数创建的套接字,是一个主动套接字,它可以主动发送请求,也可以和另一个主动套接字传输数据。listen()
函数可以将原来的主动套接字转换为被动套接字,被动套接字是用来等待用户请求的。操作系统会为被动套接字创建一些用来接收用户请求的数据结构,比如半连接队列和已连接队列。参数
sockfd
: 主动套接字的文件描述符。backlog
: 在 Linux 中,backlog
表示已连接队列的最大大小。5. accept()
accept()
函数会从已连接队列中接收一条连接,并为其创建一个 socket 文件用于通信。参数
listenfd
: 被动套接字的文件描述符,用于监听连接的。addr
: 用于接收远端的地址。通常addr
会指向一个struct sockaddr_storage
结构体。addrlen
: 这是一个传入传出参数,传入的时候,它会告诉内核addr的实际长度,避免缓冲区溢出。传出的时候,它会告诉应用程序远端地址的实际长度:IPv4 为 16,IPv6 为 28。Example
至此,服务器端主要函数就介绍完了。我们一起来看一下服务器端的主要流程:
6. connect()
上面描述的是服务器端的流程,客户端的流程要简单许多。
第一步和服务器端一样,调用
socket()
创建一个套接字。第二步,客户端需要调用connect()
向服务器端发起连接请求。参数
sockfd
: 客户端套接字。addr
: 服务器端的地址。addrlen
: 服务器端地址的长度。Example
注意,客户端调用
connect()
之前,可以但没有必要去调用bind()
。因为内核会自动确定 IP 地址,并选择一个可用的端口。如果是
SOCK_STREAM
类型的套接字,调用connect()
将激发 TCP 的三次握手过程,且仅在连接建立成功或出错时才会返回。6.1 TCP 的三次握手
接下来,我们一起来看一下著名的 TCP 三次握手过程(这一次我们不需要死记硬背:))。

注意,默认情况下 socket 网络编程都是阻塞式的,当然也有非阻塞式的,我们在后面的章节会讲。所谓阻塞式就是应用程序发起函数调用后不会直接返回,由操作系统内核处理完成之后才会返回。
解读
服务器端调用
socket()
,bind()
和listen()
完成了被动套接字的准备工作,所谓”被动“就是等待客户端来连接。然后调用accept()
去接收已建立的连接,如果没有已建立的连接,accept()
就会陷入阻塞。客户端调用
socket()
完成主动套接字的准备工作。然后调用connect()
主动发起连接请求,这时候就会触发 TCP 的三次握手过程,connect()
陷入阻塞。接下来的事情就由操作系统内核的网络协议栈完成,具体过程如下:
- 客户端的网络协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号为 j,客户端进入
SYNC_SENT
状态。
- 服务器端的网络协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 j+1,表示对 SYN 包 j 的确认;同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 k,服务器端进入
SYN_RCVD
状态。
- 客户端网络协议栈收到 ACK 之后,就会让应用程序从
connect()
调用返回。客户端到服务器端的单向连接也就建立成功了,客户端进入ESTABLISHED
状态。同时客户端网络协议栈也会对服务器端的 SYN 包进行应答,应答数据为 k+1。
- 应答包到达服务器端后,服务器端网络协议栈会让
accept()
调用返回。这时服务器端到客户端的单向连接也建立成功,服务器端也进入ESTABLISHED
状态。
归纳总结
这一章,我们主要讲了服务器端和客户端的建立连接的主要流程,并且结合 socket 网络编程详细讲解了 TCP 的三次握手。
- 服务器端通过
socket()
,bind()
,listen()
完成被动套接字的准备工作;通过accept()
函数接收已建立的连接。
- 客户端通过
socket()
完成主动套接字的准备工作;通过connect()
主动发起连接请求。
参考文献
- TCP/IP Illustrated, Volume 1: The Protocols
欢迎您在底部评论区留言,我们一起交流学习~
- 作者:Thomas He
- 链接:https://notion-next-lovat-ten.vercel.app/article/networkprogramming/3
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章