《老鸟python 系列》视频上线了,全网稀缺资源,涵盖python人工智能教程,爬虫教程,web教程,数据分析教程以及界面库和服务器教程,以及各个方向的主流实用项目,手把手带你从零开始进阶高手之路!点击 链接 查看详情




详解TCP协议栈

阅读:233922656    分享到

上一节,我们手把手教大家编写一个基于 TCP 协议的服务器和客户端,并完成了简单的通信功能。我们还给大家简单分析了套接字的各个函数的意思,其实 TCP 协议是很复杂的,但是,我们只需要了解几个重要的概念,就可以使用 TCP 协议完成公司的项目的开发。

本节课,我们详细讲解 TCP 协议的 connect,listen,accept,send,recv,close 等这些函数在面试的时候经常被问到,但是,大多数老码农弄错了,教科书上更是错误百出。

在详细讲解 TCP 协议的这些函数之前,大家一定要弄明白一个概念,TCP 协议是在操作系统中实现的,我们应用层调用的套接字提供的所有函数,全都是和操作系统的 TCP 协议栈交互的。比如,套接字的 send 函数是发送数据到本机操作系统的 TCP 协议栈缓冲区中,而协议栈缓冲区才负责把这些数据通过网络发送到对方的机器上。我们本节课会给大家详细的讲解和试验。

listen 参数的意义

在讲解 listen 之前,我们先规定一个概念,就是调用 listen 函数的进程叫服务器,调用 connect 函数的进程叫客户端。

listen 参数代表的是协议栈缓冲区内能存放的 connect 连接成功的最大套接字个数,假设这个参数设置为 2,当 客户端 connect 成功一次,服务器端就生成一个套接字放在协议栈缓冲区中,这时候再来一个客户端 connect 成功了,协议栈缓冲区就有两个套接字了,此时,协议栈缓冲区能存放的套接字个数已经达到了上限。

如果再有客户端 connect 过来,我们的操作系统一般会直接拒绝该连接请求,或者操作系统会做一个超时暂存,就是把该连接请求暂缓,如果一定时间内,协议栈缓冲区有了空位(有套接字被 accept 取出),就和客户端进行连接,连接成功后,就把该套接字放入协议栈缓冲区。

一般情况下,我们设置 listen 参数为 5,至于为什么是 5 而不是 6,其实,没多大啥区别,因为都够用哈哈,因为我们连接成功一个客户端,在协议栈缓冲区生成一个套接字,就会马上调用 accept 函数从该协议栈缓冲区取出一个套接字,这个存放套接字的协议栈缓冲区就如一个漏斗,倒入(connect)的水马上就漏出来(accept)了,除非我们特意的不调用 accept 取出套接字,下面我们就做一个实验。

'''
此为服务器程序
'''
import socket

sv_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)  # 创建套接字
sv_ipport = ("0.0.0.0", 8889)          # 监听的 ip 和端口
sv_socket.bind(sv_ipport)              # 绑定服务器地址
sv_socket.listen(2)                    # 协议栈缓冲区最大套接字存放个数
print('启动服务器,等待客户端连接......')

while True:
    input("阻塞中......")  # 让代码阻塞在此处
    server_socket, addr = sv_socket.accept()  # 从协议栈缓冲区中取出一个套接字

server_socket.close()  # 关闭套接字
'''
此为客户端程序
'''
import socket

client_socket = socket.socket()     # 创建套接字
ip_port = ("127.0.0.1", 8889)       # 要连接的服务器的 ip 和端口
client_socket.connect(ip_port)      # 连接服务器
input("已经连接服务器成功......")    # 让代码阻塞在此处,防止客户端退出
client_socket.close()

我们启动服务器程序后,程序会阻塞在 input("阻塞中......") 这条语句,然后启动两个客户端(注意,客户端启动后不要关闭),你会发现这两个客户端都连接成功了, 当你启动第三个客户端的时候,大概等待一两秒钟,服务器就拒绝了我们的连接,此时,你会发现客户端报如下错误。

Traceback (most recent call last):
  File "E:/tcpproject/tcpclient.py", line 5, in <module>
    client_socket.connect(ip_port)      # 连接服务器
ConnectionRefusedError: [WinError 10061] 由于目标计算机积极拒绝,无法连接。

这个时候,我们随便在服务器端输入一些内容,敲回车,目的是让服务器通过 input("阻塞中......") 这条语句,然后服务器进程开始执行 server_socket, addr = sv_socket.accept() 这条语句,该语句从服务器的 TCP 协议栈缓冲区中取出第一个和客户端连接成功生成的套接字,这个时候,我们协议栈缓冲区就多出来一个空位了,然后我们第四次运行客户端程序,你会发现第四个客户端就可以连接成功了。

connect 和三次握手

当服务器执行完 listen 后,服务器就处于被动监听状态,一直等待着客户端的连接到来,当客户端执行 connect 函数后,如果 connect 成功,服务器就会在 TCP 协议栈缓冲区内生成一个和该客户端通信的套接字,这个套接字是在服务器端调用 accept 函数时返回给我们的程序的。那么客户端的 connect 到底是怎么和服务器建立连接的呢?整个建立连接的过程如下图所示。

基于 TCP 的应用程序之间如何保证双方的线路是通的,就是靠 TCP 三次握手来确定的,首先服务器进程调用的 listen 函数使得服务器进程的套接字处于进入 LISTENING 状态,然后,进入控制台下,输入命令 natstat -a 列出进程正在使用的 TCP 套接字,此时我们可以看到 IP 为 0.0.0.0 端口为 8889 的套接字所处的状态是 LISTENING。

然后我们执行客户端程序,客户端进程调用 connect 函数触发三次握手。第一次握手:建立连接时,客户端发送 SYN 包到服务器,并进入 SYN_SEND 状态,等待服务器确认;第二次握手:服务器收到 syn 包,必须确认客户的 SYN,同时自己也发送一个 SYN 包,此时服务器进入 SYN_RECV 状态;第三次握手:客户端收到服务器的 SYN+ACK 包,向服务器发送确认包,此包发送完毕,客户端和服务器进入 ESTABLISHED 状态,完成三次握手。当然,由于建立三次握手过程变化很快,我们的 natstat -a 命令只能看到连接成功或不成功的结果,如下图是握手成功的结果。

如果同学们想看每次握手中套接字变化的状态,可以使用抓包工具,比如 wireshark,具体使用方式请自行搜索。

accept 做了什么

首先,大多数同学都误认为 accept 对应的是客户端的 connect,其实大家通过我前面的理论阐述和实例验证,应该知道这是错误的认知了。

一句话,accept 和 connect 并没有任何关系,accept 函数只是从本地 TCP 协议栈缓冲区的套接字存放队列中取出一个已经完成三次握手的套接字而已。 accept 每取出一个套接字,协议栈缓冲区的套接字存放队列就移除一个最早完成的套接字, listen 函数的参数可接收最大三次握手的个数就会空出来一个。

大家注意,不要把 accept 取出的套接字和我们服务器监听的套接字混淆了,我们可以把服务器监听的套接字看做电话的主机,accept 返回的套接字看做电话的分机,主机负责监听客户端的连接,分机负责和客户端发送和接收数据。

send 和协议栈发送缓冲区

我们在应用程序中调用套接字的 send 函数往对方发送数据,并不是直接发送到网络上,而是把数据发送到我们本机操作系统的 TCP 协议栈缓冲区中,这个套接字缓冲区是由操作系统维护的,至于何时把缓冲区中的数据发送到网络上,是由操作系统决定的。

下面我们就做个试验,在服务器代码中的 recv 语句之前多加上一句代码 input("阻塞中......"), 我们让客户端 send 两次,然后在服务器端随便输入内容,敲回车通过 input 语句,就调用 recv 一次,看看是不是一次性就能收全。

'''
此为服务器程序
'''
import socket

sv_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)  # 创建套接字
sv_ipport = ("0.0.0.0", 8889)          # 监听的 ip 和端口
sv_socket.bind(sv_ipport)              # 绑定服务地址
sv_socket.listen(5)                    # 协议栈缓冲区最大套接字存放个数
print('启动服务器,等待客户端连接......')
server_socket, addr = sv_socket.accept()

input("阻塞中......")  # 让代码阻塞在此处

client_data = server_socket.recv(1024).decode("utf-8")  # 调用 1 次
print("收到客户端信息:%s" % client_data)

server_socket.close()  # 关闭套接字
'''
此为客户端程序
'''
import socket

client_socket = socket.socket()     # 创建套接字
ip_port = ("127.0.0.1", 8889)       # 要连接的服务器的 ip 和端口
client_socket.connect(ip_port)      # 连接服务器

client_data = input("请输入要发送给服务器的信息:")
client_socket.send(client_data.encode("utf-8"))    # 第一次发送信息

client_data = input("请输入要发送给服务器的信息:")
client_socket.send(client_data.encode("utf-8"))    # 第二次发送信息

client_socket.close()

通过实验,我们发现在等客户端调用两次 send 后,再调用服务器端的 recv,确实一次性收到了客户端两次发送的全部内容。

注意:系统存在缓冲区默认字节一般为 8 K,这个值我们可以通过 setsockopt 进行修改。调用 send 发送的数据如果大于发送缓冲区所能容纳的数据量,send 函数就会阻塞。我们可以让服务器代码阻塞在 recv 函数之前,然后让客户端不停的 send 数据,直到客户端的协议栈发送缓冲区满了,你就会发现客户端代码就会阻塞在 send 函数处了,然后你让服务器端的 recv 接收一小部分,客户端就又可以发送数据了。这个原理就如水管被堵塞,储水的水池慢慢的就满了,然后让水管流出一部分水,就又可以往水池里面加水了,同学们可以做个试验验证一下。

recv 和协议栈接收缓冲区

我们在应用程序中调用套接字的 recv 函数,该函数是从本机操作系统的 TCP 协议栈接收缓冲区中取出数据,这个协议栈缓冲区是由操作系统维护的,我们在程序中调用 recv 取出一部分数据,操作系统的协议栈接收缓冲区就去除掉这一部分数据。

recv 的参数代表一次性从协议栈接收缓冲区内取出多少数据,一般情况下我们会写个 while 循环不停的取数据,直到取出我们想要的完整数据。在此,该参数我设为 1024 只是随手一挥。注意,recv 并不是要取出这么多的数据才返回,只要协议栈接收缓冲区里面有数据,我们的 recv 函数就马上返回,recv 的参数只是代表一次性最多取多少数据。

下面我们就做个试验,我们让客户端 send 一次,让服务器 recv 多次,直到取出客户端发送的所有数据为止。

'''
此为服务器程序
'''
import socket

sv_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)  # 创建套接字
sv_ipport = ("0.0.0.0", 8889)          # 监听的 ip 和端口
sv_socket.bind(sv_ipport)              # 绑定服务地址
sv_socket.listen(5)                    # 协议栈缓冲区最大套接字存放个数
print('启动服务器,等待客户端连接......')

server_socket, addr = sv_socket.accept()
while True:
    client_data = server_socket.recv(3).decode("utf-8")  # 每次从接收缓冲区取出 2 个字节流
    if (client_data == "exit") or (not client_data):     # 判断客户端是否申请结束会话或客户端是否退出
        break
    print("收到客户端信息:%s" % client_data)

server_socket.close()  # 关闭套接字
'''
此为客户端程序
'''
import socket

client_socket = socket.socket()     # 创建套接字
ip_port = ("127.0.0.1", 8889)       # 要连接的服务器的 ip 和端口
client_socket.connect(ip_port)      # 连接服务器
while True:
    client_data = input("请输入要发送给服务器的信息:")  # 输入内容的长度请大于 2
    client_socket.send(client_data.encode("utf-8"))     # 发送信息
    if client_data == "exit":  # 输入 exit 客户端退出循环
        break

client_socket.close()

套接字发送和接收数据图解

最后,我们用一个简单的图来描述一下基于 TCP 协议的应用程序发送和接收数据的流程图。

close 的注意事项

套接字的 close 函数完成了断开连接,也就是把操作系统维护的 TCP 协议栈缓冲区中的套接字移除,这个移除的过程被称为四次挥手,具体是如何挥手的,有兴趣的话,大家可以自行搜索。

你不需要了解四次挥手的过程,你只要知道 close 做了什么,以及如何安全的使用 close 即可。

无论是在服务器进程还是客户端进程,谁先调用 close,谁的协议栈缓冲区就往对方发送一个空字节流。所以,我们在客户端和服务器的代码中都有一个判断 recv 收到的内容是不是空字符串,如果为空,我们就退出循环,然后调用 close 函数,那这个后调用的 close 起到了什么作用呢?这个大家可以理解为一个礼貌的回应,就是告诉先调用 close 的一方:“好的,我收到了,我关闭了,你也关闭吧”,然后先调用 close 的一方也就关闭了套接字。

如果你的进程结束了,但是你代码中忘记调用 close,此时,操作系统会替你给对方发送一个空字节流(和你调用 close 触发的行为一样)。这是因为进程使用的套接字是操作系统分配的文件对象,当进程结束后,操作系统会检查该进程是否有未归还的资源,如果有的话,操作系统会自动回收,你进程中忘记调用套接字的 close 函数,套接字这个文件对象就没有归还给操作系统,此时,操作系统就会强制回收该套接字,然后善后套接字未完成的行为(给通信的一方发送空字节流)。

最后说一句,虽然操作系统有给我们擦屁股的功能,但是我们也要养成一个好习惯,就是套接字使用完,一定要调用 close 函数关闭套接字。

send 和 sendall 区别

python 的套接字提供两个可以发送数据的函数 send 和 sendall,当然这两个函数都是把数据发送到本地协议栈发送缓冲区中,但是,他们在发送数据的方式方面有些不同。

send 函数发送数据时,如果返回值大于 0 代表发送数据成功,如果是其它值则代表发送错误。注意,调用一次 send,并不一定发送完你要发送的所有数据,send 的返回值是发送到缓冲区的字节流个数,所以要根据返回值判断,编程继续发送剩余的部分。

举个栗子:我们在应用层调用 send 发送的内容有 20000 个字节流,但协议栈发送缓冲区目前还剩下的空间只能接受 8000 个字节流就满了,这时候 send 的返回值就是 8000,还剩余 12000 个字节流没有发送到协议栈接收缓冲区内, 这时候,我们就需要继续调用 send 继续发送剩余的内容。

而 python 的哲学讲究简单即是真理,所以 python 给我们提供了 sendall 函数,sendall 函数会自动判断每次发送的字节流个数,然后从总内容中删除已发送的部分,继续自动发送剩余的部分,一直到把数据全部发送完成。 实际上,sendall 函数是对 send 函数的封装,当发送的数据太大时(没法一次性把数据发送到协议栈发送缓冲区内),sendall 是不停的调用 send 发送数据,直到全部发送完才返回,注意,sendall 发送成功的返回值为 None。下面的代码就是 sendall 是如何实现的。

def sendall(data):
    len = 0
    while True:
        len = s.send(data[len:])
        if not len:
            break
    return None

本节重要知识点

弄明白套接字的 listen,accept,connect 分别做了什么。

弄明白套接字的 send,recv 的关系。

要知道套接字是应用层级的,协议栈缓冲区是操作系统层级的,这个认知很重要。

作业

写一个程序,让服务器端在 recv 函数之前停下来,而客户端不停的调用 send 发送数据,看看当协议栈发送缓冲区满的时候,send 是阻塞还是报错。


如果以上内容对您有帮助,请老板用微信扫一下赞赏码,赞赏后加微信号 birdpython 领取免费视频。


登录后评论

user_image
伊利杀白
2020年11月18日 05:35 回复

写的好棒!对本小白理解很有帮助~~


user_image
nekocode
2020年8月16日 17:44 回复

感谢啊、就是需要这样的文章、通俗易懂、小白一个准备买你的视频了


user_image
sleeping-knight
2020年7月10日 04:37 回复

就喜欢这样的老师。再抽象的东西。在一个好的老师用心的表达下。也变的通俗易懂了。如果有视频教学。那就更容易理解了。再次感谢上面的老师。


user_image
1不2
2020年7月1日 15:28 回复

哈哈,我一个学通信的觉得你写得很赞!


user_image
Zhaoyang
2020年4月27日 06:42 回复

很荣幸能看到这篇通俗易懂的讲解,大神666


user_image
貔卡貅
2020年3月21日 16:00 回复

厉害了, world哥


user_image
blue-cloud
2020年1月26日 16:43 回复

厉害厉害,长知识了,给赞


user_image
M3小蘑菇
2020年1月14日 06:59 回复

读到一半,忍不住来评论下,写的很形象,很好,很适合入门级来学习,感谢分享!


user_image
freezerush
2019年8月27日 10:47 回复

厉害厉害


user_image
1不2
2019年5月28日 03:14 回复

通信小白膜拜,我学编程的过来了解一下通信,写的很好,通熟易懂


user_image
hacker9090
2019年3月26日 09:08 回复

通俗易懂!良心教程


user_image
Dirax
2019年1月25日 23:05 回复

写的真是太棒了,通俗易懂


user_image
CharlieJiang
2019年1月6日 17:54 回复

写得真好