[Python学习日记-77] 网络编程中的 socket 开发 —— 基于 TCP 和 UDP 的套接字
[Python学习日记-77] 网络编程中的 socket 开发 —— 基于 TCP 和 UDP 的套接字
简介
基于 TCP 的套接字
基于 UDP 的套接字
简介
前面Python学习日记-76介绍了 socket 的用法和函数,在本篇当中我们会分别基于 TCP 和 UDP 来创建客户端和服务器端的套接字(socket)通信,并讲述这一过程当中会遇到的一些 bug 和相应的解决方法。
基于 TCP 的套接字
一、简单的基于 TCP 套接字通信
简单的 socket 通信即进行一次客户端和服务器端的一来一回的消息传递,传输完毕后就断开链接结束通信,socket 通信类似于我们日常接触到的打电话流程,我们就以打电话为例实现一个简单的 socket 通信,代码如下
服务器端:
import socket
ip_port = ('127.0.0.1',8080) # 电话卡,0-65535: 0-1024 给操作系统使用
info_size = 1024 # 收发消息的尺寸
# 1、买手机
phone = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
# 2、插手机卡
phone.bind(ip_port)
# 3、开机
phone.listen(5) # 5代表最大监听数
# 4、等电话链接
print('starting...')
conn,client_addr = phone.accept() # conn相当于电话线,服务器端可以用它来收消息和发消息
print('接到来自%s的电话' % client_addr[0])
# 5、收,发消息
data = conn.recv(info_size) # 1、单位: bytes 2、1024代表最大接收1024个bytes
print('客户端的数据:',data)
conn.send(data.upper()) # 发消息
# 6、挂电话
conn.close()
# 7、关机
phone.close()
客户端:
import socket
ip_port = ('127.0.0.1',8080) # 这里指的是服务器端的 ip 和端口,客户端的 ip 和端口无需设定
info_size = 1024
# 1、买手机
phone = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
# 2、拨号
phone.connect(ip_port) # 0-65535: 0-1024 给操作系统使用
# 3、发,收消息
phone.send('hello'.encode('utf-8')) # 只能发送字节类型
data = phone.recv(info_size) # 收消息
print(data.decode('utf-8'))
# 4、挂电话
phone.close()
注意:服务器端用到了两个 socket,分别是 phone 和 conn,一个用来绑定 ip 和端口并等待链接,另一个用于收发消息;客户端只用到了一个 socket,用来连接和收发消息。
代码输出如下:
服务器端:基于 TCP 的 socket 通信需要先启动服务器端
服务器端刚启动时会进入等待链接的状态,这是 accept() 的作用,当启动客户端后将会开始进行数据的发送
客户端:
二、加上通信循环与链接循环
1、通讯循环
在前面简单的套接字当中我们只能客户端和服务器端之间一对一的发送一条消息,如果我们有很条数据需要传递那效率也太低了吧,下面就要在原代码的基础上加上通信循环,让客户端和服务器端能够实现一个一次链接可以传输多条数据,代码如下
服务器端:
import socket
ip_port = ('127.0.0.1',8080)
info_size = 1024
phone = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
phone.bind(ip_port)
phone.listen(5) # 5代表最大监听数
print('starting...')
conn,client_addr = phone.accept()
print('接到来自%s的电话' % client_addr[0])
while True:
data = conn.recv(info_size)
print('客户端的数据:',data)
conn.send(data.upper())
conn.close()
phone.close()
客户端:
import socket
ip_port = ('127.0.0.1',8080)
info_size = 1024
phone = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
phone.connect(ip_port)
while True:
msg = input('>>: ').strip()
phone.send(msg.encode('utf-8'))
data = phone.recv(info_size)
print(data.decode('utf-8'))
phone.close()
代码输出如下:
服务器端:
客户端:
从输出结果来看已经是可以在一次链接当中连续发送多条数据了,但是还是存在一些问题,例如当服务器端重启时可能会出现端口占用的问题、当客户端输入为空时出现卡死的问题和客户端中断后服务器端进入死循环的问题,解决方案如下所示
问题1:服务器端重启时出现端口占用
现象分析:
这是由于服务器端结束进程了,但 TCP 仍然存在四次挥手的 time_wait 状态,所以占用了该地址端口(如果还存在疑问建议从三方面入手了解:1.查看该篇文章深入了解一下 TCP 的三次握手和四次释放的过程;2.了解一下 syn 洪水攻击;3.了解在服务器高并发的情况下如何优化存在大量 time_wait 状态的方法)
解决方法:
在代码层面可以在服务器端加入一条 socket 配置,来重用 ip 和端口,代码如下所示
phone.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) # 该项配置需要加在 bind 前
如果服务器端部署的是 Linux 或 类 Unix 系统上,则可以通过调整内核参数来解决,如下所示
以 linux 为例,发现系统存在大量TIME_WAIT状态的连接,通过调整linux内核参数解决
vi /etc/sysctl.conf
编辑配置文件,添加下面的内容:
net.ipv4.tcp_syncookies = 1 # 表示开启 SYN Cookies。当出现 SYN 等待队列溢出时,启用 cookies 来处理,可防范少量 SYN 攻击,默认为0,表示关闭;
net.ipv4.tcp_tw_reuse = 1 # 表示开启重用。允许将 TIME-WAIT sockets 重新用于新的 TCP 连接,默认为0,表示关闭;
net.ipv4.tcp_tw_recycle = 1 # 表示开启 TCP 连接中 TIME-WAIT sockets 的快速回收,默认为0,表示关闭;
net.ipv4.tcp_fin_timeout = 30 # 修改系統默认的 TIMEOUT 时间
然后执行 /sbin/sysctl -p 让修改好的参数生效
问题2: 客户端输入为空时出现卡死
现象分析:
现在从现象来看有四种可能
1、客户端没有发送出去;
2、客户端发送出去了,服务器端也发送回来了,但是客户端的接收出了问题;
3、客户端发送出去了,但服务器端根本没有接收到客户端发送的数据;
4、服务器端接收到数据了,但是在发送时出了问题。
这个时候我们可以先从客户端入手进行分析,先在客户端的发送和接收中插入输出,看看输出到了那一步卡住,代码如下所示
# 客户端
# ...
while True:
msg = input('>>: ').strip()
print('msg: ',msg)
phone.send(msg.encode('utf-8'))
print('消息发送了')
data = phone.recv(info_size)
print(data.decode('utf-8'))
# ...
客户端的输出:
从客户端的输出来看,是卡在了客户端接收数据的那一步,但是客户端真的发送了数据出去吗?我们先看看服务器端的输出再做判断,服务器端输出如下
从服务器端的输出来看,它的 recv() 根本就没有执行,那就是说明服务器端根本就没有收到数据,这样就说明客户端以为自己把数据发送出去了,但是根本就没有,为什么会出现这种现象呢?这就涉及到之前讲计算机基础与网络的时候说到的,我们所编写的 Python 程序都只是应用层的应用程序,但是应用程序并不能直接操作网卡来通信,而在这一个传输的过程当中我们需要通过系统来调用网卡,从而来实现通信,具体过程为:当客户端接收到用户输入的 msg 后,send() 会把 msg 从客户端的内存当中读取到操作系统的内存当中,这个时候操作系统会参照 TCP 协议(socket 实例化时选定的协议,type=socket.SOCK_STREAM)对客户端组织的数据进行封包(完成传输层、网络层、数据链路层和物理层的工作),然后再调用网卡发送给服务器端,当服务器端的网卡接收到数据之后服务器端的操作系统会进行解包,把数据放在操作系统的内存当中,服务器端进行 recv() 时,就会从服务器端的操作系统内存当中读取数据到服务器端的内存当中,这样整个发送过程就完成了,反之亦然。
回到问题当中,当用户在客户端输入空时,客户端 send() 会把空从客户端的内存中给到操作系统的内存当中,当操作系统看到你给的是一个空时它就会认为你什么都没有给操作系统,那样更不会说为客户端调用任何协议来进行封装并发送了。那服务端就根本没有说到任何数据,那它也一直在 recv() 等待数据的接收。
解决方法:
经过分析发现,这个问题主要出现在客户端程序当中,那我们对客户端进行代码的改进,代码如下
import socket
ip_port = ('127.0.0.1',8080)
info_size = 1024
phone = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
phone.connect(ip_port)
while True:
msg = input('>>: ').strip()
if not msg:continue # 如果用户输入的数据为空则结束本次循环
phone.send(msg.encode('utf-8'))
# 如果输入''或空格的话应用程序会发送''或空格给操作系统,让操作系统转发给网卡,但是操作系统收到的是空,认为是没有东西的,
# 所以并不会并行TCP/IP的封装,所以服务端根本就没有收到数据
data = phone.recv(info_size)
print(data.decode('utf-8'))
phone.close()
改进后的代码输出如下:
问题3:客户端中断后服务器端进入死循环
这个问题出现的时机是在服务器端和客户端正常连接的情况下,客户端断开了连接,然后服务器端进入了不断接收信息的死循环,现象如下图所示
现象分析:
查看服务器端的代码发现,在等待接收客户端数据的 recv() 是调用 conn 的,而 conn 是一个已经实例化的套接字,它代表的是一个 TCP 的双向链接,它存在的要素就是链接双方的存在,但是上面的现象就是客户端单方面的断开了,导致服务器端中的 conn 失去了存在的意义,导致它不会继续等待对方(客户端)发来消息,而是一直现实接收到了空消息,并且继续执行下面的 send(),结合问题2的分析知道,应用程序所执行收发消息并不是直接发送的,而是会先发送给操作系统,然后再由操作系统按照 TCP 协议进行封装,最后再发送出去的,在这里接收到的是空,发送自然也是空,而且对方(客户端)也已经不存在了,那操作系统并不会真正的发送数据出去,所以就进入了一个死循环。
这并不是一个好现象,在前面我们介绍循环的时候已经说过死循环的这个现象,它会占用大量资源,最终会导致服务器资源耗尽而陷入死机瘫痪等现象,如果在生产环境中出现这种现象是非常严重的。
解决方法:
针对这种现象 Windows 和 Linux 是两种现象,Linux 会出现上述的这种一直陷入死循环的现象,而 Windows 则会抛出 ConnectionResetError 错误(实际测试发现 Windows 11,Python 3.12.4 的环境也变为了出现死循环的现象),针对这两种不同的现象给出两种解决方案,如下代码所示
# Linux
# ...
while True:
data = conn.recv(info_size)
# 如果客户端单方面终止连接在 Linux 上服务器端会出现死循环不断收空的情况
if not data:break # 正常情况下不会收到空,所以当接收到空的时候,就会结束循环
print('客户端的数据:',data)
conn.send(data.upper())
# ...
# Windows
# ...
while True:
try:
data = conn.recv(info_size)
print('客户端的数据:',data)
conn.send(data.upper())
except ConnectionResetError: # 如果客户端单方面终止连接在 Windows 上会抛出该错误,但是经测试 Python 3.12.4 已经不抛出该错误,变为和 Linux 的一直了
break
# ...
下面以服务器端出现死循环的现象为例,完整的服务器端的改进代码如下所示
import socket
ip_port = ('127.0.0.1',8080)
info_size = 1024
phone = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
phone.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
phone.bind(ip_port)
phone.listen(5) # 5代表最大监听数
print('starting...')
conn,client_addr = phone.accept()
print('接到来自%s的电话' % client_addr[0])
while True:
try:
data = conn.recv(info_size)
if not data:break
print('客户端的数据:',data)
conn.send(data.upper())
except ConnectionResetError:
break
conn.close()
phone.close()
改进后的代码输出如下:
2、链接循环
在完成通讯循环后我们实现了一次链接就能发送多条数据,但是在客户端断开连接后服务器端也跟着断开了,这种模式违背了服务器端的一些初衷。在 C/S 架构当中的服务器端是可以一对多的提供服务的,如下图所示
上图的是最终想要达到的方案,但是由于目前还没有学习到并发编程,所以并无法实现 accept() 创建链接后给到收发服务,然后继续去等待客服端的链接,而目前只能实现 accept() 创建链接后就直接进行收发服务,对于其他客户端的链接请求就会让它们挂起等待,直到当前已创建链接的客户端结束通信后才会与另一个客户端创建链接,而这里挂起等待的最大数量是由 listen() 中的数字所决定的,即 listen(5) 为最大挂起等待数量为5个客户端,具体形式如下图所示
上图的模式更像是用一个循环为不同的客户端提供服务,我们把这种循环称之为链接循环,对于,实现代码如下
服务器端:
import socket
ip_port = ('127.0.0.1',8080)
info_size = 1024
phone = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
phone.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
phone.bind(ip_port)
phone.listen(5) # 5代表最大监听数
print('starting...')
while True: # 链接循环
conn,client_addr = phone.accept()
print('接到来自%s的电话' % client_addr[0])
while True: # 通讯循环
try:
data = conn.recv(info_size)
if not data:break
print('客户端的数据:',data)
conn.send(data.upper())
except ConnectionResetError:
break
conn.close()
phone.close()
客户端:
import socket
ip_port = ('127.0.0.1',8080)
info_size = 1024
phone = socket.socket(family=socket.AF_INET,type=socket.SOCK_STREAM)
phone.connect(ip_port)
while True:
msg = input('>>: ').strip()
if not msg:continue # 如果用户输入的数据为空则结束本次循环
phone.send(msg.encode('utf-8'))
# 如果输入''或空格的话应用程序会发送''或空格给操作系统,让操作系统转发给网卡,但是操作系统收到的是空,认为是没有东西的,
# 所以并不会并行TCP/IP的封装,所以服务端根本就没有收到数据
data = phone.recv(info_size)
print(data.decode('utf-8'))
phone.close()
代码输出如下:
用上面客户端的代码创建客户端1、客户端2、客户端3,启动服务器端,客户端从1-3依次启动,并都输入 hello,效果如下
服务器端只处理了客户端1的数据,而客户端2和客户端3都处于挂起等待的状态,当客户端1断开连接后服务器端会立马与客户端2创建链接并接收先前发送的数据来进行处理,如下图所示
如此类推,客户端3也会在客户端2断开后才会和服务器端进行连接,然后再处理先前发送的数据,如下图所示
基于 UDP 的套接字
UDP 协议相比起 TCP 协议来说是比较简单的,它不需要建立通道,也没有重传机制,所以开销会更小,效率会更高,传输速度也更快。在使用 socket 模块实现 UDP 通信的时候会用到 SOCK_DGRAM 参数,DGRAM 是数据报的意思,即一个数据就是一个报文,相对于 TCP 来说是比较独立的,下面就来看看应该如何实现基于 UDP 的套接字通信吧。
一、简单的基于 UDP 套接字通信
前面介绍基于 TCP 的套接字的时候就看到了 socket 的参数是非常多的,这里我们可以直接使用“from socket import *”把 socket 模块中的属性都导入到程序的名称空间当中(仅此一次,不推荐使用),这样可以省下重复敲很多个 socket. 的力气,实现代码如下
服务器端:
from socket import *
ip_port = ('127.0.0.1',8080)
info_size = 1024
server = socket(AF_INET,SOCK_DGRAM) # DGRAM是数据报的意思,一个数据就是一个报文
server.bind(ip_port)
while True:
# 基于 udp 的套接字通信只能使用 recvfrom() 和 sendto()
data,client_addr = server.recvfrom(info_size)
print(data)
server.sendto(data.upper(),client_addr)
server.close()
客户端:
from socket import *
ip_port = ('127.0.0.1',8080)
info_size = 1024
client = socket(AF_INET,SOCK_DGRAM)
while True:
msg = input('>>: ').strip()
# 基于 udp 的套接字通信只能使用 recvfrom() 和 sendto()
client.sendto(msg.encode('utf-8'),ip_port) # 发空实际上并不是发空,因为会带ip和端口数据
data,server_addr = client.recvfrom(info_size)
print(data,server_addr)
client.close()
代码输出如下:
由于 UDP 协议是无连接的,所以无论先启动服务器端还是先启动客户端都不会报错。
那基于 UDP 的套接字通信会出现基于 TCP 的套接字通信时候的发送空值的问题吗?直接看看现象,如下图所示
从输出来看,并不会遇到基于 TCP 的套接字通信的问题,这是因为用户即使发送空值客户端也会自动的加上 ip 和端口的信息,这样在操作系统看来传输的数据并不是空值,所以才会正常进行传输。
二、基于 UDP 套接字实现的聊天和查询
有的小伙伴拿基于 UDP 的套接字通信和基于 TCP 的套接字通信比个高低,其实这两个并没有可比性,因为双方的关注点并不在一个地方,基于 TCP 的套接字通信更加注重的是数据传输的可靠性,更加适用于文件传输、邮件收发、网页传输等;基于 UDP 的套接字通信则更加注重的是数据传输的高效性,更加适用于聊天通信(QQ、微信)、信息查询(NTP、DNS)等。
1、聊天通信(QQ、微信)
由于 UDP 是无连接,所以可以同时让多个客户端和服务器端通信,代码如下
服务器端:
from socket import *
ip_port = ('127.0.0.1',8080)
info_size = 1024
server = socket(AF_INET,SOCK_DGRAM)
server.bind(ip_port)
while True:
msg,client_addr = server.recvfrom(info_size)
print('来自[%s:%s]的一条消息: \033[1;44m%s\033[0m' % (client_addr[0],client_addr[1],msg.decode('utf-8')))
back_msg = input('回复消息: ').strip()
server.sendto(back_msg.encode('utf-8'),client_addr)
server.close()
客户端1和客户端2:
from socket import *
name_dic = {
'武大郎':('127.0.0.1',8080),
'潘金莲':('127.0.0.1',8080),
'武松':('127.0.0.1',8080),
'西门庆':('127.0.0.1',8080),
}
info_size = 1024
client = socket(AF_INET,SOCK_DGRAM)
while True:
name = input('请选择聊天对象: ').strip()
while True:
msg = input('请输入消息,回车发送: ').strip()
if msg == 'quit()':break
if not msg or not name or name not in name_dic:continue
client.sendto(msg.encode('utf-8'),name_dic[name])
back_msg,server_addr = client.recvfrom(info_size)
print('来自【%s:%s】的一条消息: \033[1;44m%s\033[0m' % (server_addr[0],server_addr[1],back_msg.decode('utf-8')))
client.close()
代码输出如下:
服务器端:
客户端1:
客户端2:
2、时间服务器(NTP)
服务器端:
from socket import *
from time import strftime
ip_port = ('127.0.0.1',8080)
info_size = 1024
server = socket(AF_INET,SOCK_DGRAM)
server.bind(ip_port)
while True:
date,client_addr = server.recvfrom(info_size)
print('===>',date)
# 定义时间格式
if not date:
time_fmt = '%Y-%m-%d %X'
else:
time_fmt = date.decode('utf-8')
back_msg = strftime(time_fmt)
server.sendto(back_msg.encode('utf-8'),client_addr)
server.close()
客户端:
from socket import *
ip_port = ('127.0.0.1',8080)
info_size = 1024
client = socket(AF_INET,SOCK_DGRAM)
while True:
date = input('请输入时间格式(如%Y-%m-%d %X)>>: ').strip()
client.sendto(date.encode('utf-8'),ip_port)
ntp_date = client.recv(info_size)
print(ntp_date.decode('utf-8'))
client.close()
代码输出如下:
服务器端:
客户端: