Python网络编程——TCP
- 2020/10/20 更新问题: UDP 和 TCP 可以绑定在同一个端口吗?
网络是令人捉摸不透的。我们想要传输的数据包有时会被丢弃,有时会被复制,有时顺序会被弄乱。如果仅使用 UDP 提供的数据协议,那么应用程序的代码还需要处理数据传输的可靠性,并提供传输发生错误时的恢复方案。但如果使用 TCP,数据包就被隐藏到协议层之下,应用程序只需要向目标机器发送流数据,TCP 会将丢失的信息重传。
TCP
工作原理
TCP 是如何提供可靠连接的呢? 下面是它的基本工作原理
- 每个 TCP 数据包都会有一个序列号,接收方通过该序列号讲响应数据包正确排序,也可以通过该序列号发现传输过程中丢失的数据包,并请求重传。
- TCP 并不使用顺序的整数作为序号,而是通过一个计数器来记录发送的字节数,例如,如果一个包含1024字节的数据包序列号为7200,那么下一个数据包的序列号就是8224。这意味着,繁忙的网络栈无需记录其是如何将数据流分割为包的,当需要进行重传的时候,可以使用另一种分割方式将数据流分割为多个新数据包,而接收方仍然可以正常的接受数据包。
- 在一个优秀的 TCP 实现中,初始序列号是随机选择的,一定程度上降低被攻破的风险。
- TCP 不通过锁步的方式进行通信,如果使用这种方式,就必须等待每个数据包都被确认接受后才能发送下一个数据包,速度非常的慢。相反,TCP 无需等待响应就能一口气发送多个数据包。在某一时刻发送方希望同时传输的数据量叫做 TCP 窗口的大小
- 接受方的 TCP 实现可以通过控制发送方的窗口大小来减缓或暂停连接。这叫做流量控制(Flow Control),这使得接受方在输入缓冲区已满的时候可以禁止更多的数据包的传输。此时如果还有数据到达,将会被舍弃。
TCP 的标准 POSIX 接口(可移植操作系统接口)分为被动监听套接字和主动连接套接字
- 被动套接字(passive socket)又叫做监听套接字(listening socket),它维护了"套接字名"——IP地址和端口号。服务器通过该套接字来接受连接请求。但是套接字不能用于发送或接受任何数据,也不表示任何实际的网络会话,而是由服务器指示被动套接字通知操作系统优先使用哪个特定的 TCP 端口号来接受连接请求。
- 主动套接字(active socket)又叫做连接套接字(connected socket),他将一个特定的IP地址以及端口号与某个正在进行远程会话的主机绑定。连接套接字只用于与该特定远程主机进行通信。可以通过该套接字发送或接收数据,可以将 TCP 的连接套接字传给另一个接受普通文件作为输入的程序,该程序可能永远也不会知道它正在进行网络通信。
被动套接字有接口 IP 地址和正在监听的端口号来唯一表示,即任何程序都无法再使用。而多个主动套接字是可以共享同一个本地套接字名的。例如有1000个客户端与一台繁忙的网络服务器都在进行 HTTP 连接。就会有1000个主动套接字都绑定到了服务器的公共 IP 地址和 TCP 的80端口,唯一表示主动套接字是如下的四元组: (local_ip, local_port, remote_ip, remote_port)
操作系统是通过这个四元组来为主动 TCP 连接命名的,接到数据包是,操作系统会检查源地址和目标地址是否与系统中某一个主动套接字相符。
一个简单的 TCP 客户端和服务端的例子:
1 | import argparse, socket |
看上去和 UDP 客户端和服务器程序很像,但 TCP 的 connect() 调用与 UDP 不同,UDP 的 connect 调用只是对绑定套接字进行了配置,设置了后续的 send(), recv() 调用锁需要的默认的远程地址,不会导致任何错误。而例子中的 connect() 调用则是真实的网络操作,会在要通信的客户端和服务端进行3次握手,这意味这 connect() 有可能失败,比如说没有运行服务器时运行客户端:
ConnectionRefusedError:
[WinError 10061] 由于目标计算机积极拒绝,无法连接。
TCP 把发送的数据简单的看做流,而流是没有开始和结束标志,TCP 会将这些流分为多个数据包。而与之相比,UDP 的意义很简单,要么是发送一个数据报,要么是接收一个数据报,每个数据报都是原子的。TCP 可能会在传输过程中把数据流分为多个大小不同的数据包,然后在接受器端将这些数据包逐步重组。调用 send() 和 recv() 对 TCP 流会有什么效果?
send() 发生时,操作系统的网络栈可能会碰到下述3种情况:
- 要发送的数据被立即被网络栈接收,这时可能由于网卡正好空闲,可以用于立即发送数据,也可能因为系统还有空间,可以将数据复制到临时发送缓冲区,这样程序就能继续运行。这些情况下,send() 会立即返回,由于发送的是整个串,返回值是整个数据串的长度。
- 另一种可能性是,网卡很忙,该套接字的发送缓冲区已满,而系统也无法或不愿为其分配更多的空间,此时 send() 默认情况下会直接阻塞进程,暂停应用程序,直到本地网络栈能够接受并传输数据。
- 最后一种情况介于2者之间,发送缓冲区几乎满了,但尚有空间,因此想要发送的部分数据可以进入发送缓冲区的队列等待发送,但剩余的数据块则必须等待。这种情况下 send() 会立即返回从数据串开始处起已经发送被接收的字节数,剩余尚未处理。
由于这个原因,有时会在网络程序的代码中看到如下方式的循环:
1 | bytes_sent = 0 |
Python socket标准库实现了 sendall() 方法,比上述有更高的效率。另外它在循环中释放了全局解释锁,因此其他的 Python 线程在所有数据完成之前不会竞争资源。
而对于 recv(),由于收到的字节不定长,Python 并没有标准库方法,操作系统内部的 recv() 实现的逻辑和发送相似:
- 如果没有任何数据,那么 recv() 会阻塞程序直到数据到达
- 如果缓冲区里只有 recv() 需要返回的部分数据,那么即使这并非全部内容,也会立即返回缓冲区中已有的数据
- 如果缓冲区内的数据已经完整就绪,那么 recv() 收到所需的全部数据
死锁
典型的 TCP 栈使用了缓冲区,这样就可以在应用程序准备好读取数据前存放到的接收到的数据,也可以在网络硬件准备好发送数据包前存放的数据。这些缓冲区的大小是有限制的,系统一般不会想让程序使用未发送的网络数据将 RAM 填满。毕竟,如果另一方尚未准备好处理数据,那么增加系统资源用于更大的缓冲区是没有意义的。
下面这个TCP服务器和客户端可能会造成死锁:
1 | import argparse |
1 | (venv) D:\my_py36\Python-Web\tcp>python tcp_deadlock.py client 127.0.0.1 32 |
运行一下,达到了我们的预期。在任何情况下像这样处理输入每次只处理一个数据块对于服务器来说是一个明智的选择,通过分块处理程序并及时发回响应,服务器限制了其任意时刻需要保存在内存中的数据量。如果服务器这样设计,即使每个客户端发送的数据多达几兆字节,服务器也能在同一时刻处理数百个客户端,而且不会使内存或者其他硬件资源难堪重负。
但当我们尝试发送大到一定程度的数据量的时候:
1 | (venv) D:\my_py36\Python-Web\tcp>python tcp_deadlock.py client 127.0.0.1 1073741824 |
送到一定大小的时候,就卡死了,客户端要比服务端多发送一些数据。为什么会停止呢?,因为服务器的输出缓冲区和客户端的输入缓冲区都会被填满,然后 TCP 就会使用滑动窗口协议而来处理这种情况,套接字会停止发送更多的数据,因为即使发送这些数据都会丢失。
为什么会导致死锁呢?考虑一下每个数据块的传输过程中都发送了什么。客户端使用 sendall() 发送数据块,然后服务端使用 recv() 来接收,处理,接着转为大写,再次使用 sendall() 发回去。由于还有数据需要发送,客户端并没有运行任何 recv() 调用,因此越来越大的数据填满了操作系统的缓冲区,导致无法接收更多的数据。
怎么解决?首先客户端和服务端可以通过套接字选项将阻塞关闭,这样像 send() 和 recv() 这样的调用在得知还不能发送数据时就会返回。第二种方法,程序可以使用某种技术同时处理来自多个输入的数据,可以采用多线程或进程,也可以采用 select() 或 poll() 等系统调用,这样当程序在接收或者发送套接字繁忙的时候等待,当他们任意一个空闲时做出响应。
告诉我们了什么呢?
- UDP 是不会发生这种事情,因为 UDP 并没有实现流量控制,当传达的数据量超出接收端的处理能力时,UDP 就会直接丢弃这些数据,由应用程序来发现数据报的丢失。
- 网络连接的每一段 TCP 栈中都有缓冲区,这些缓冲区能够暂时保存数据,这样一来当数据包传到接收端时,即使接收端没有运行 recv() 调用,也不需要丢弃这些数据包。当然缓冲区的大小是有限的,当不断尝试写入的数据始终没有被接受或处理,次数就无法再写数据,直到数据被读取出来前,即缓冲区有空余空间后,写数据操作才可以继续进行。
- 没有采用锁的协议的可能涉及的危险情况之一,如果一个协议并没有严格要求服务器在客户端请求发送完成后才读取完整的请求,然后再返回完整的响应,那么就有可能发生上述的死锁情况。
半开连接
例子中在客户端套接字完成发生之后使用了 shutdown(),这解决了一个重要的问题。如果服务器在遇到文件结束符之前一直永远的读数据,那么客户端如果避免在套接字上进行完整的 close() 操作。客户端如果防止运行很多 recv() 来接收服务器的响应呢?解决方法就是将套接字"半关",即在一个方向上永久关闭,但并不销毁,在这种情况下服务器不会再读任何数据,但仍能向客户端发送剩余的响应。SHUT_WR
表示不知道通信对方何的输出何时结束,表示调用方不再向套接字写数据。而通信对方也会不再读取任何数据并且认为遇到了文件结束符。
有时当需要创建单向的套接字,往往会先创建双向套接字然后当套接字连接后立马运行 shutdown() 来关闭不需要的连接方向,这样操作系统的缓冲区也不会被无意义的填充。立即运行 shutdown() 也能够为通信对方提供更加清晰的错误信息,这样对方也不会混淆,也不会尝试在不需要发送数据的方向上发,否则意外数据可能会将缓冲区填满,由于这些数据永远不会被读取,导致无法写入,导致死锁。
UDP 和 TCP 可以绑定在同一个端口吗?
可以的。
首先,一个 UDP 应用是可以在不同的 IP 上绑定同一个端口,如绑定在127.0.0.1:1060 和 本机的 IP 地址下的 1060 端口的。无论任何时候,IP 网络栈都不会把 UDP 端口看作是一个可以连接或者正在使用的单独实体。相反,IP 网络栈关注的是 UDP “套接字名”,这是由 IP 接口和 UDP 端口号组成的二元组。如果只是端口号冲突无伤大雅。需要留意的是,客户端发送数据只会被一个服务器接受,这个服务器就是你 UDP 客户端指定的那个。
而对于 TCP 连接需要由四元组来形成,即(src_ip, src_port, dst_ip, dst_port),当连接请求来了之后,服务器调用 accept 函数生成了一个新的 socket 这个端口占用的仍是 80(假设这个应用监控着 80)这些新的 socket 本地的 ip 和 port 都相同,远程的不同。这就是它可以建立很多很多的连接的原因。
最后 UDP 和 TCP 都可以绑定在同一个端口。再想想端口的定义:端口是一种抽象的软件结构(包括一些数据结构和 I/O 缓冲区)。应用程序通过系统调用与某端口建立连接(binding)后,传输层传给该端口的数据都被相应进程所接收,相应进程发给传输层的数据都通过该端口输出。在TCP/IP协议的实现中,端口操作类似于一般的 I/O 操作,进程获取一个端口,相当于获取本地唯一的 I/O 文件,可以用一般的读写原语访问之。
而端口号类似于文件描述符,用于区别不同端口。由于 TCP/IP 传输层的两个协议 TCP 和 UDP 是完全独立的两个软件模块(操作系统提供的),因此各自的端口号也相互独立,如 TCP有一个255号端口,UDP 也可以有一个255号端口,二者并不冲突。
Python网络编程——TCP