为什么又回头看了 Qt 的网络模块
前段时间在做一个桌面端的数据采集工具,需要同时干几件事:接收局域网内设备用 UDP 广播上来的状态、跟一台采集卡保持长连接收数据、还要定期去后端拉配置。这类需求用 Python 写,最直接的想法是 socket + requests + asyncio 拼起来。但工具本身是 Qt 写的界面,一旦把 asyncio 事件循环和 Qt 事件循环混在一起,调试成本就上来了。
于是我改用 Qt 自带的网络模块,让所有 IO 都跑在 Qt 的事件循环里。用下来感受是:Qt 的网络类不算花哨,但胜在和界面、信号槽体系是同一套东西,心智负担小。这篇文章把这三层能力按实际使用顺序讲一遍,重点放在那些文档里提得不多、但写起来会卡住的地方。
需要先说明环境:本文代码基于 PyQt6(Qt 6.5 及以上),PySide6 的 API 基本一致,差异只在信号连接写法上。下面涉及的具体类名和信号名我都在 Qt 6 官方文档里核对过,但不同小版本可能有细微差别,如果你用的是 Qt 5,部分枚举名(比如 QAbstractSocket.SocketState)的写法会不同,这点要注意。
UDP:QUdpSocket 适合什么,不适合什么

先讲 UDP,因为它最简单,也最容易被滥用。
QUdpSocket 继承自 QAbstractSocket,但因为 UDP 无连接,它实际上没有连接状态,你调用 bind() 之后就能收发数据报。它的核心信号只有一个值得记:readyRead,表示有数据报到达,可以读了。
python
# Python 3.11 + PyQt6
import sys
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtNetwork import QUdpSocket, QHostAddress
class UdpReceiver(QObject):
packet_received = pyqtSignal(bytes, str)
def __init__(self, port: int, parent=None):
super().__init__(parent)
self._sock = QUdpSocket(self)
# ShareAddress 允许多个进程绑定同一端口,做广播监听时常用
ok = self._sock.bind(
QHostAddress.SpecialAddress.Any,
port,
QUdpSocket.BindFlag.ShareAddress | QUdpSocket.BindFlag.ReuseAddressHint,
)
if not ok:
raise RuntimeError(f"bind failed: {self._sock.errorString()}")
self._sock.readyRead.connect(self._on_ready_read)
def _on_ready_read(self):
# 一次 readyRead 可能对应多个数据报,必须循环读到没有为止
while self._sock.hasPendingDatagrams():
datagram = self._sock.receiveDatagram()
data = bytes(datagram.data())
sender = datagram.senderAddress().toString()
self.packet_received.emit(data, sender)
if __name__ == "__main__":
from PyQt6.QtCore import QCoreApplication
app = QCoreApplication(sys.argv)
r = UdpReceiver(9000)
r.packet_received.connect(
lambda d, a: print(f"from {a}: {d!r}")
)
sys.exit(app.exec())
几个实际写的时候才会注意的点:
必须循环读。 readyRead 是个"至少有数据"的信号,不是"每个数据报触发一次"。如果你只读一次,剩下的数据报会留在缓冲区里,而且可能不再触发新的信号,导致数据卡住。用 hasPendingDatagrams() 循环是标准做法。
receiveDatagram() 和 readDatagram() 的区别。 Qt 5.8 之后引入了 QNetworkDatagram,receiveDatagram() 返回一个对象,里面带了发送方地址、端口、目标地址等信息。readDatagram() 是老接口,需要自己传缓冲区。新代码建议用前者,信息更全。
UDP 不适合传大文件。 单个 UDP 数据报理论上限接近 64KB,但实际链路上通常会因为 MTU 分片,丢一片整个报就废了。我的经验是,控制单包在 1200 字节以内比较稳。要传大块数据,用 UDP 做发现、TCP 做传输,这是常见组合。
广播和组播要显式设置。 想发广播,需要 setSocketOption 配合 QNetworkInterface 找到正确的网卡。组播(joinMulticastGroup)在有多张网卡(比如同时连着 WiFi 和有线)的机器上容易走错接口,出问题时先查网卡绑定。
TCP:QTcpServer 和 QTcpSocket 的配合

TCP 是 Qt 网络模块里用得最多的部分,也是坑比较集中的地方。
服务端用 QTcpServer,它只负责 accept,不负责收发数据。accept 之后拿到一个 QTcpSocket,这个 socket 才是真正的连接。
python
# Python 3.11 + PyQt6
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtNetwork import QTcpServer, QTcpSocket, QHostAddress
class TcpServer(QObject):
message_received = pyqtSignal(str, bytes)
def __init__(self, port: int, parent=None):
super().__init__(parent)
self._server = QTcpServer(self)
if not self._server.listen(QHostAddress.SpecialAddress.Any, port):
raise RuntimeError(f"listen failed: {self._server.errorString()}")
self._server.newConnection.connect(self._on_new_connection)
# 每个 socket 一个缓冲区
self._buffers: dict[QTcpSocket, bytearray] = {}
def _on_new_connection(self):
sock = self._server.nextPendingConnection()
if sock is None:
return
self._buffers[sock] = bytearray()
sock.readyRead.connect(lambda s=sock: self._on_ready_read(s))
sock.disconnected.connect(lambda s=sock: self._on_disconnected(s))
def _on_ready_read(self, sock: QTcpSocket):
buf = self._buffers[sock]
buf.extend(bytes(sock.readAll()))
# 简单协议:4 字节大端长度 + 载荷
while len(buf) >= 4:
length = int.from_bytes(buf[:4], "big")
if len(buf) < 4 + length:
break
payload = bytes(buf[4:4 + length])
del buf[:4 + length]
peer = f"{sock.peerAddress().toString()}:{sock.peerPort()}"
self.message_received.emit(peer, payload)
def _on_disconnected(self, sock: QTcpSocket):
self._buffers.pop(sock, None)
sock.deleteLater()
这里最核心的问题是粘包 。TCP 是字节流,readyRead 触发时你拿到的可能是半个包、一个半包、或者好几个包。上面代码用"长度前缀 + 载荷"的定长头协议来拆包,这是工程上最省事的方案。也有人用换行符分隔,适合文本协议,但要注意载荷里不能出现分隔符。
【踩坑提醒】 不要在 readyRead 里只调用一次 readAll() 就以为拿全了。如果对端一次性发了 1MB,readAll() 可能只返回了当前缓冲区里的部分;剩下部分会在后续的 readyRead 里到。用上面的循环拆包逻辑,能自然处理这种情况。
另一个容易忽略的点是对象的生命周期 。nextPendingConnection() 返回的 socket 的 parent 是 server,但连接断开后不会自动销毁。如果不调用 deleteLater(),长时间运行的服务端会积累大量僵尸 socket。我在一个跑了三天的采集服务上就遇到过句柄数缓慢上涨的问题,加 deleteLater() 之后稳定了。
客户端 这边,QTcpSocket 的用法和服务端 socket 基本一致,主要区别是要处理连接状态:
python
sock = QTcpSocket()
sock.connected.connect(lambda: print("connected"))
sock.errorOccurred.connect(lambda e: print("error:", e, sock.errorString()))
sock.connectToHost("127.0.0.1", 8000)
connectToHost 是异步的,不会阻塞。要等 connected 信号发出后再写数据。如果直接连着写,数据会进缓冲区,等连接建立后自动发出,但错误处理会变得很难写,建议还是等信号。
HTTP:QNetworkAccessManager 够用吗

到了 HTTP 这一层,很多人第一反应是直接用 requests。在纯脚本里没问题,但在 Qt 应用里,requests 是同步阻塞的,放在主线程会卡界面,放子线程又要处理跨线程信号,反而更麻烦。
Qt 提供的是 QNetworkAccessManager,它是异步的,天然和事件循环配合。用法是发请求、拿 QNetworkReply、连它的 finished 信号。
python
# Python 3.11 + PyQt6
from PyQt6.QtCore import QObject, QUrl, pyqtSignal
from PyQt6.QtNetwork import (
QNetworkAccessManager, QNetworkRequest, QNetworkReply
)
class HttpClient(QObject):
done = pyqtSignal(int, bytes)
def __init__(self, parent=None):
super().__init__(parent)
self._mgr = QNetworkAccessManager(self)
def get(self, url: str, timeout_ms: int = 5000):
req = QNetworkRequest(QUrl(url))
req.setTransferTimeout(timeout_ms) # Qt 5.15+ 才有
reply = self._mgr.get(req)
reply.finished.connect(lambda r=reply: self._on_finished(r))
def _on_finished(self, reply: QNetworkReply):
status = reply.attribute(
QNetworkRequest.Attribute.HttpStatusCodeAttribute
)
body = bytes(reply.readAll())
err = reply.error()
reply.deleteLater()
if err != QNetworkReply.NetworkError.NoError:
self.done.emit(-1, reply.errorString().encode())
return
self.done.emit(int(status or 0), body)
QNetworkAccessManager 有个特点:一个 manager 实例内部维护连接池和缓存,所以整个应用共用一个 manager 比较合理,不要每次请求都 new 一个。这也是为什么上面的代码把 manager 作为成员变量。
它支持的能力包括:GET/POST/PUT/DELETE、自定义 header、上传表单(QHttpMultiPart)、HTTPS(依赖系统或 Qt 自带的 TLS 后端)、重定向(setRedirectPolicy)。日常对接后端 API 是够用的。
但有几个地方和 requests 体验不一样:
没有 raise_for_status()。 状态码得自己从 HttpStatusCodeAttribute 里取,错误要查 reply.error()。HTTP 4xx/5xx 在 Qt 里不一定被当作 NetworkError,具体行为跟版本有关,稳妥做法是同时检查状态码和 error。
超时控制。 setTransferTimeout 是 Qt 5.15 引入的,Qt 6 里可用。如果你在更老的版本上,只能自己起一个 QTimer,到点手动 reply.abort()。
Cookie 和认证。 默认 manager 不共享 cookie,需要手动设置 QNetworkCookieJar。这一块我没有在生产项目里深入用过,只是知道有这么个机制,具体行为建议查官方文档。
三层能力怎么选

写到这里,可以给一个选型参考。这三者不是替代关系,而是覆盖不同层次的需求。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| QUdpSocket | 无连接、开销小、天然支持广播组播 | 不保证送达和顺序,不适合大数据 | 设备发现、状态心跳、局域网实时小包 |
| QTcpServer/QTcpSocket | 可靠有序、可控性强、能自定义协议 | 要自己处理粘包、连接管理、心跳 | 长连接数据采集、自定义二进制协议 |
| QNetworkAccessManager | 异步、和事件循环天然契合、HTTP 语义完整 | 只覆盖 HTTP 层,底层控制弱 | 调用后端 REST API、下载文件、上报数据 |
我的实际做法通常是组合使用:UDP 负责发现和心跳,TCP 负责持续数据流,HTTP 负责和后端交互。三者都跑在同一个 Qt 事件循环里,不需要引入额外的并发模型。
线程归属这件事值得单独说
Qt 网络类都有一个硬性约束:socket 对象必须在它所属线程的事件循环里使用 。如果你在主线程创建 socket,然后在子线程里调用 write(),行为是未定义的,可能直接崩。
正确做法有两种:
- 把整个网络对象(包括 socket)
moveToThread到工作线程,在该线程里创建、使用、销毁。 - 全部放主线程,用信号槽把数据处理逻辑丢给子线程。
对于轻量场景(几个连接、每秒几十个包),放主线程完全够用,Qt 的事件循环处理这点 IO 毫无压力。只有连接数上百、或者单连接吞吐很高时,才需要考虑多线程拆分。
【关键结论】 不要为了"看起来专业"就急着上多线程。Qt 的网络模块本身是异步非阻塞的,单线程事件循环能扛的量比很多人想象的大。先跑起来,用 profiler 看瓶颈在哪,再决定要不要拆。
一点收尾
Qt 的网络模块设计思路很统一:所有 IO 都是异步的,通过信号通知结果,对象归属线程决定使用边界。UDP、TCP、HTTP 三层共享这套模型,学会一层,另外两层迁移成本很低。
如果你的项目是 Qt 界面 + 网络通信,用 Qt 自带的网络类通常比混入 requests、asyncio 更省心。反过来,如果是纯后端服务、没有界面,那标准库 socket 或 asyncio 可能更轻。工具是给场景用的,别反过来。
至于 TLS 配置、代理、证书校验这些更细的话题,本文没有展开,它们在 Qt 里也都有对应的类(QSslSocket、QNetworkProxy),属于另一个篇幅的内容了。