Unix 网络编程卷一阅读随笔。

TCP 与 socket 系统调用

[C2.4, 35] TCP 客户先与某个给定服务器建立一个连接(三次握手),再跨该连接与那个服务器交换数据,然后终止这个连接(四次挥手)。

[C2.4, 37-39] 结合 socket 网络编程接口,TCP 的三次握手与四次挥手:

  1. 服务器被动打开 :服务器首先调用socket() 函数创建一个套接字,然后调用 bind() 函数将该套接字绑定到一个本地地址和端口。接着,服务器调用 listen() 函数使该套接字进入监听状态,准备好接受外来的连接。

  2. 服务器调用 accept():服务器调用accept() 函数,该函数阻塞等待客户端的连接请求。当有客户端请求连接时,accept()返回一个新的套接字描述符,用于与客户端进行通信。原来的监听套接字继续保持监听状态。

  3. 客户端发起主动打开 :客户端调用socket() 函数创建一个套接字,然后调用 connect() 函数向服务器发起连接请求。此时,客户端和服务器进行 TCP 的三次握手:

    • 客户端发送 SYN 包(第一次握手)。
    • 服务器收到 SYN 包,回复 SYN+ACK 包(第二次握手)。
    • 客户端收到 SYN+ACK 包,回复 ACK 包(第三次握手),连接建立。
  4. 服务器 accept 返回 :当服务器收到客户端的连接请求(即 SYN 包)后,accept() 函数返回,提供一个新的套接字描述符用于与客户端通信。此时,三次握手完成,客户端和服务器可以开始通信。

  5. 数据传输:连接建立后,客户端和服务器可以通过套接字进行数据传输。服务器一般调用read()(或recv())函数阻塞等待接收数据,客户端调用write()(或send())函数发送数据。

  6. 连接关闭 :数据传输完成后, 通常 由客户端调用 close() 函数发起主动关闭连接,进行四次挥手:

    • 客户端发送 FIN 包,表示主动关闭连接(第一次挥手)。
    • 服务器收到 FIN 包,回复 ACK 包(第二次挥手),此时服务器进入 CLOSE_WAIT 状态。
    • 服务器调用 close() 函数,发送 FIN 包(第三次挥手),表示准备关闭连接。
    • 客户端收到服务器的 FIN 包,回复 ACK 包(第四次挥手),此时连接关闭。

    客户端进入 TIME_WAIT 状态,等待一定时间后彻底关闭连接,确保服务器收到 ACK 包。

无论是客户端还是服务器,任何一端都可以调用 close() 执行主动关闭。通常情况是客户端执行主动关闭,但是某些协议(比如 HTTP/1.0)却是由服务器执行主动关闭。

TCP 状态转移图

结合 这篇文章 的 TCP 三次握手和四次挥手一起理解上图和下图。

TCP 连接的分组交换(包括连接建立、数据传输和连接终止)

上图中,服务器对客户端请求的 ACK 确认是伴随着其应答消息一起发送的,这种做法称为捎带(piggybacking)。它通常在服务器处理请求并产生应答的时间少于 200ms 时发生。如果处理耗时更长,如 1s,那么我们看到的将先是 ACK 确认,后是应答。

socket 基本结构

socket 地址

IPv4 套接字地址:struct sockaddr_in {}

IPv6 套接字地址:struct sockaddr_in6 {}

通用套接字地址:struct sockaddr {}

当某种套接字地址作为一个参数传递给任何套接字函数时,套接字总是以引用的形式来传递,且 这些套接字函数必须能处理任意协议族的套接字地址结构 。故此,需要将它们强转转换成通用套接字地址,传递给这些函数。这些函数有:bind(), connect(), accept() 等。

[C3.3, 73-74] 有时候,应用进程需要告诉内核套接字地址的长度(内核就知道从进程复制多少数据进来了,这样内核在操作该结构时不至于越界),且内核需要告诉应用进程套接字地址的长度(它告诉进程内核在该结构中究竟存储了多少数据)。这就需要值 - 结果类型的参数,传入指向地址长度的指针、返回地址长度的结果再存储在这个指针中,供应用进程使用。

IP 地址转换

[C3.6, 81-83]

ASCII 字符串格式的 IP 与二进制格式的网络地址相互转换:

IPv4 ASCII 到 Network 地址:inet_aton(), inet_addr()
Network 地址到 IPv4 ASCII:inet_ntoa()

使用于 IPv4 和 IPv6 的新的转换函数,p=presentation, n=numeric:

ASCII 到 Network 地址:inet_pton()
Network 地址到 ASCII:inet_ntop()

输入输出字节比实际少

[C3.9, 88-89]

对于字节流套接字(如 TCP 套接字)上的 read()和 write()函数所表现的行为不同于通常的文件 I/O。字节流套接字上调用 read()或 write(),输入或输出的字节数可能比实际请求的数量少,但这不表示发生了错误。

这个现象的原因是:内核中用于套接字的缓冲区可能已达到了极限。此时需要调用者再次调用 read()或 write()函数,以输入或输出剩余的字节。

你是否有印象,好多 read()都在 whlie 循环下执行,除了由于网络的不确定性和延迟,也有上述原因(个人理解)。

socket 基本编程

socket C/S 通信

基本 TCP 客户 / 服务器程序的 socket 函数

connect()

[C4.3, 99-100]

在客户端调用 connect()函数前不必非得调用 bind()函数——因为如果需要的话,内核会确定源 IP 地址,并选择一个临时端口作为源端口。

bind()

[C4.4, 100-102]

调用 connect()函数将触发 TCP 三次握手,而且仅在连接建立成功或出错时才返回。

bind()函数把一个本地协议地址赋予一个套接字。

  • 对于 IP 协议,协议地址是 32 位 IPv4 地址或 128 位 IPv6 地址与 TCP/UDP 端口号的组合。
  • 对于 TCP,调用 bind()函数可以指定一个端口号、或指定一个 IP 地址、也可以两者都指定或都不指定。
  • 一般服务器会绑定一个众所周知的端口号,以便被大家(客户)认识。
  • 如果不绑定端口号,不管服务器还是客户端,当调用 listen()或 connect()时,内核就要为相应的套接字分配一个临时端口。

如果客户端或服务器不绑定 IP 地址,会怎么处理?

  • 一般地,客户端通常不把 IP 地址绑定到它的套接字上。当连接(服务器)套接字时,内核将根据所用外出网路接口来选择源 IP 地址。
  • 一般地,服务器通常需要把 IP 地址绑定到它的套接字上。当未绑定时,内核就把客户端在三次握手中发送的 SYN 报文的目的 IP 地址作为服务器的源 IP 地址。
  • 使用通配地址时,一般为 htonl(INADDR_ANY) / htonl(in6addr_any),表示让内核指定 IP 地址。
  • 指定端口为 0 时,表示让内核选择一个临时端口——要查看分配的端口,使用 getsockname()函数返回协议地址,进而获取端口。

listen()

listen()函数仅由 TCP 服务器调用。

当 socket()函数创建一个套接字时,它被假设为一个主动套接字。也就是说,它被假设为是一个将调用 connect()发起连接的客户套接字。但是,在服务端没有 connect()调用,listen()将一个未 connect 的套接字转换成一个被动套接字,指示内核应该接收指向该套接字的连接请求。

listen()监听最大 backlog 个连接,其中 backlog 是未完成连接队列个数(未完成 TCP 三次握手的分节在此队列)和已完成连接队列(已完成 TCP 三次握手的分节在此队列)个数之和。若分节完成三次握手,则将被转移至已完成连接队列的队尾。

TCP 为监听套接字维护的两个队列

accept()

accept()函数仅由 TCP 服务器调用,用于从已完成连接队列的队头返回下一个已完成连接的客户套接字(返回值称为已连接套接字,accept 第一个参数称为监听套接字)。

  • 监听套接字:一个服务器通常仅仅创建一个监听套接字,它在该服务器的生命周期内一直存在。
  • 已连接套接字:随着 TCP 三次握手的完成而由内核创建的客户套接字,并在服务器完成对该客户的服务时,被关闭。

fork()

Unix 系统中派生新进程的唯一方法——fork()及其变体。

在进程中调用一次 fork(),它却返回两次:

  • 在父进程中返回一次,返回值是新派生的子进程的 PID;
  • 在子进程中返回一次,返回值是 0。

子进程的返回值是 0,是因为子进程的父进程唯一,它可以通过 getpid()获取父进程的 PID。而父进程只能通过记录返回值来存储多个子进程的 PID。

父进程中调用 fork()之前打开的所有描述符,在 fork()返回之后,可在父进程与子进程之间共享

利用这个特性,在网络服务器中,可在 accept()返回客户套接字后,fork()一个子进程,由子进程处理这个客户的请求,而父进程可关闭这个客户套接字,从而可以再次 accept()其它连接请求。

对于“父进程可关闭这个客户套接字”,那么为什么子进程中还能使用这个客户套接字

这是因为描述符(文件或套接字)都有一个引用计数。引用计数在文件表项中维护,它是当前打开着的描述符的个数。

当 fork()返回后,父进程中先前打开的所有描述符,会在父进程与子进程间共享(也就是被复制),即每个描述符的引用计数都会加 1。因此,一个描述符真正的清理和资源释放要等到其引用计数值达到 0 时才发生。

getsockname()

使用 name 一词令人误解,getsockname()和 getpeername()函数返回是本端或对端的协议地址——对于 IP 协议来说,就是 IP 地址和端口号。

需要这两个函数理由:

  • 在没有调用 bind()的客户端上,connect()成功返回后,getsockname()用于返回由内核赋予该连接的本地 IP 地址和端口号。
  • 在以端口号 0 调用 bind()时,getsockname()用于返回由内核动态赋予的本地端口号。
  • getsockname()可用于获取某个套接字的地址族。
  • 在服务端,可以使用 getsockname()返回已连接套接字(客户套接字)的本地 IP 地址和端口号。