2. 嵌入式系统与并发编程基础
网络编程,说白了就是让两个程序隔着网线聊天。Qt 把这事包装得挺舒服,你不需要直接跟 socket 文件描述符打交道。但舒服归舒服,坑也不少。我刚开始用 Qt 做网络通信时,就吃过阻塞模式的亏——界面卡死,用户差点砸电脑。
这一章,咱们把 QTcpSocket 和 QTcpServer 的核心 API 捋一遍。重点说清楚阻塞和非阻塞到底怎么选,信号与槽在网络里怎么用才不出事。
2.1 QTcpSocket 核心 API
QTcpSocket 继承自 QAbstractSocket,再往上就是 QIODevice。所以它天生就能用 read、write、bytesAvailable 这些接口。但网络通信跟本地文件读写不一样——数据不是一口气到的,你得学会跟它“异步相处”。
| API | 作用 | 我常用的场景 |
|---|---|---|
connectToHost(host, port) | 发起连接 | 客户端主动连服务器 |
disconnectFromHost() | 断开连接 | 清理资源时调用 |
write(data) | 发送数据 | 返回实际写入字节数 |
readAll() | 读取所有可用数据 | 配合 readyRead 信号使用 |
bytesAvailable() | 当前可读字节数 | 分包处理时判断 |
state() | 返回当前连接状态 | 调试时打印状态变化 |
waitForConnected() | 阻塞等待连接完成 | 仅限非 UI 线程使用 |
waitForReadyRead() | 阻塞等待数据到达 | 同步模式专用 |
我的习惯: 只要涉及 UI 线程,绝对不用 waitFor 系列。曾经有个项目,我在主线程调了 waitForReadyRead,结果服务器没回数据,界面直接卡了 30 秒。用户反馈说“软件死了”,从那以后我定了规矩——UI 线程只走信号驱动。
2.2 QTcpServer 核心 API
QTcpServer 比 socket 简单得多。它的核心任务就两个:监听端口、接受连接。
QTcpServer *server = new QTcpServer(this);
if (!server->listen(QHostAddress::Any, 8080)) {
qWarning() << "监听失败:" << server->errorString();
return;
}
// 有新连接时,QTcpServer 会自动创建 QTcpSocket
connect(server, &QTcpServer::newConnection, this, [=]() {
while (server->hasPendingConnections()) {
QTcpSocket *client = server->nextPendingConnection();
// 处理新客户端...
}
});
这里有个细节:nextPendingConnection() 返回的 socket 对象,默认是 不自动删除 的。我见过不少新手忘记释放,导致内存泄漏。建议在客户端断开时调用 deleteLater()。
我曾经踩过的坑: 在 newConnection 信号里直接 connect 子线程的槽函数,结果 socket 对象在主线程创建,槽函数在子线程执行——跨线程信号槽导致数据错乱。后来我统一用 moveToThread 把 socket 挪到工作线程再处理。
2.3 阻塞模式 vs 非阻塞模式
阻塞模式,就是调用 waitFor 系列函数后,线程停在那等。非阻塞模式,靠信号通知你“数据来了”。
什么时候用阻塞?我个人只在两种场景用:
- 单元测试:同步写法容易验证逻辑
- 独立工作线程:比如一个专门发心跳包的线程,阻塞等回复没问题
其他情况,尤其是 UI 线程,一律非阻塞。你想想看,用户点个按钮,界面卡 5 秒,谁受得了?
| 对比维度 | 阻塞模式 | 非阻塞模式 |
|---|---|---|
| 线程占用 | 调用线程一直等 | 调用线程立即返回 |
| 代码写法 | 顺序执行,像同步调用 | 回调/信号驱动,逻辑分散 |
| 适用场景 | 后台线程、测试代码 | UI 线程、高并发服务器 |
| 超时处理 | waitFor 带超时参数 | 用 QTimer 或信号超时 |
| 资源开销 | 线程可能空转 | 事件驱动,CPU 利用率高 |
核心原则: 非阻塞是 Qt 网络编程的默认选择。阻塞模式只在你明确知道“这个线程就是用来等 I/O”时才用。别偷懒,别在主线程用 waitFor。
2.4 信号与槽在网络中的应用
Qt 网络通信的灵魂,就是信号与槽。几个关键信号你得烂熟于心:
- connected:连接建立成功。这时候可以开始发数据了。
- disconnected:连接断开。记得清理资源,调用 deleteLater。
- readyRead:新数据到达。这是最常用的信号,但要注意——一次信号可能只收到半个包。
- bytesWritten:数据写入底层缓冲区。可以用来做流量控制。
- errorOccurred:出错了。别忽略它,打印日志至少能帮你定位问题。
为什么说 readyRead 信号容易出问题?因为 TCP 是流协议,你发 100 字节,对端可能分两次收到:第一次 60 字节,第二次 40 字节。如果你直接 readAll 然后解析,很可能解析失败。
我的做法是:维护一个接收缓冲区,每次 readyRead 就把数据追加进去,然后检查缓冲区是否包含一个完整的消息包。不够就等下一次信号。
connect(socket, &QTcpSocket::readyRead, this, [=]() {
m_buffer.append(socket->readAll());
// 假设消息格式:4字节长度 + 消息体
while (m_buffer.size() >= 4) {
quint32 len = *reinterpret_cast<quint32*>(m_buffer.data());
if (m_buffer.size() < 4 + len) break; // 还没收完
QByteArray msg = m_buffer.mid(4, len);
m_buffer.remove(0, 4 + len);
processMessage(msg);
}
});
小技巧: 用 QDataStream 配合 QTcpSocket,可以自动处理长度和字节序。但注意 QDataStream 默认用大端序,跟你的协议对得上才行。
2.5 知识结构图:网络通信核心流程
下面这张图,把客户端和服务器的核心交互画清楚了。你看一眼,就能明白信号在哪个环节触发。
2.6 避坑指南:信号与槽的线程安全问题
Qt 的信号与槽默认是 队列连接(QueuedConnection),如果发送者和接收者不在同一个线程。这意味着槽函数会在接收者所在线程的事件循环里执行,不会立即触发。
这本身是好事,但有个陷阱:如果你在子线程里 emit 一个信号,主线程的槽函数收到时,socket 可能已经被删了。我遇到过几次崩溃,都是因为这个。
解决办法:
- 用
QPointer<QTcpSocket>包裹 socket 对象,槽函数里先判空 - 或者在断开连接时,主动 disconnect 所有信号
- 最稳妥:把 socket 的生命周期交给它所在线程管理,不要跨线程 delete
我曾经犯过的错: 在服务器收到 newConnection 后,直接把 socket 指针传给一个工作线程的槽函数。工作线程处理完数据后,在主线程把 socket 删了。结果工作线程还在用这个指针——野指针崩溃。后来我改用 moveToThread,让 socket 跟着工作线程走,问题才解决。
2.7 小结
QTcpSocket 和 QTcpServer 的 API 不难,难的是理解“数据是流过来的,不是整包倒过来的”。记住三条:
- UI 线程只用非阻塞模式,信号驱动
- readyRead 信号里一定要做分包处理
- 跨线程用信号槽时,注意对象生命周期
把这些基础打牢,后面讲多线程并发服务器的时候,你才不会手忙脚乱。