type
status
date
slug
summary
tags
category
icon
password
激动人心的时刻终于要开始了!这一章,我们将学习 TCP 网络编程!要知道,这个世界上绝大多数网络服务,传输层协议用得可都是 TCP 哦!
不过在具体编写程序之前,我们先来讲一讲 client-server 网络编程模型(C/S模型)。

1. C/S 网络编程模型

C/S 模型在我们日常生活中随处可见,比如网上购物,游戏,聊天等等软件,用的都是 C/S 模型。C/S 模型比较简单,它的一般流程如下图所示:
notion image
  1. 当一个客户端需要某个服务时,它会像对应的服务器发送一个请求。请求的格式是事先双方约定好的,以保证服务器可以解析这个请求。
  1. 服务器接收到请求后,会解析并处理这个请求。
  1. 服务器处理完请求后,会给客户端发送一个响应。响应可以是处理后的结果,也可以是指引客户端下一步操作的指示。
  1. 客户端接收到响应后,会解析响应并处理响应(当然客户端也可能什么都不做)。
服务器端是我们要关注的重点。它需要事先监听在一个众所周知的端口上,然后等待客户端的请求。一旦有客户端连接,服务器端就要消耗一定的计算机资源为它服务。服务器端是需要同时为成千上万的客户端服务的,因此,保证服务器端在海量的客户端访问时依然能保持稳定和高效,就至关重要。
客户端相对来说简单许多,它向服务器的监听端口发起连接请求。连接建立之后,他就可以和服务器端进行通信了。

2. socket()

客户端和服务器端要进行通信,第一件要做的事情就是双方创建通信端点。那怎么创建通信端点呢?答案是socket(),它会创建一个通信端点,并返回一个指向该端点的文件描述符。
参数
domain: 是用来指定协议族的。AF_INET表示 IPv4 互联网协议;AF_INET6表示 IPv6 互联网协议;AF_UNIXAF_LOCAL用来本地通信的。
type: 套接字的类型。SOCK_STREAM表示字节流套接字,SOCK_DGRAM表示数据报套接字。
protocol: 设计之初原本是用来指定通信协议的,但现在基本没用,因为现在前面两个参数domaintype就可以唯一确定一个协议。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 三次握手过程(这一次我们不需要死记硬背:))。
notion image
注意,默认情况下 socket 网络编程都是阻塞式的,当然也有非阻塞式的,我们在后面的章节会讲。所谓阻塞式就是应用程序发起函数调用后不会直接返回,由操作系统内核处理完成之后才会返回。
解读
服务器端调用socket()bind()listen()完成了被动套接字的准备工作,所谓”被动“就是等待客户端来连接。然后调用accept()去接收已建立的连接,如果没有已建立的连接,accept()就会陷入阻塞。
客户端调用socket()完成主动套接字的准备工作。然后调用connect()主动发起连接请求,这时候就会触发 TCP 的三次握手过程,connect()陷入阻塞。
接下来的事情就由操作系统内核的网络协议栈完成,具体过程如下:
  1. 客户端的网络协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号为 j,客户端进入SYNC_SENT状态。
  1. 服务器端的网络协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 j+1,表示对 SYN 包 j 的确认;同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 k,服务器端进入SYN_RCVD状态。
  1. 客户端网络协议栈收到 ACK 之后,就会让应用程序从connect()调用返回。客户端到服务器端的单向连接也就建立成功了,客户端进入ESTABLISHED状态。同时客户端网络协议栈也会对服务器端的 SYN 包进行应答,应答数据为 k+1。
  1. 应答包到达服务器端后,服务器端网络协议栈会让accept()调用返回。这时服务器端到客户端的单向连接也建立成功,服务器端也进入ESTABLISHED状态。

归纳总结

这一章,我们主要讲了服务器端和客户端的建立连接的主要流程,并且结合 socket 网络编程详细讲解了 TCP 的三次握手。
  • 服务器端通过socket()bind()listen()完成被动套接字的准备工作;通过accept()函数接收已建立的连接。
  • 客户端通过socket()完成主动套接字的准备工作;通过connect()主动发起连接请求。

参考文献

  • TCP/IP Illustrated, Volume 1: The Protocols
 
💡
欢迎您在底部评论区留言,我们一起交流学习~
高性能网络编程(4)——TCP编程(二)高性能网络编程(2)——套接字地址