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 知识结构图:网络通信核心流程

下面这张图,把客户端和服务器的核心交互画清楚了。你看一眼,就能明白信号在哪个环节触发。

QTcpServer 与 QTcpSocket 核心交互流程 QTcpServer listen(port) 开始监听 newConnection 信号触发 nextPendingConnection() 获取客户端 socket readyRead / disconnected QTcpSocket connectToHost(host, port) connected 信号触发 write(data) 发送数据 readyRead 接收数据 disconnected 断开 TCP 连接 数据收发 数据流与信号驱动 客户端 write → 网络 → 服务器 readyRead 服务器 write → 网络 → 客户端 readyRead ⚠ 注意:TCP 是流协议 一次 readyRead 可能只收到半个包,需要自行组包

2.6 避坑指南:信号与槽的线程安全问题

Qt 的信号与槽默认是 队列连接(QueuedConnection),如果发送者和接收者不在同一个线程。这意味着槽函数会在接收者所在线程的事件循环里执行,不会立即触发。

这本身是好事,但有个陷阱:如果你在子线程里 emit 一个信号,主线程的槽函数收到时,socket 可能已经被删了。我遇到过几次崩溃,都是因为这个。

解决办法:

  • 用 QPointer<QTcpSocket> 包裹 socket 对象,槽函数里先判空
  • 或者在断开连接时,主动 disconnect 所有信号
  • 最稳妥:把 socket 的生命周期交给它所在线程管理,不要跨线程 delete

我曾经犯过的错: 在服务器收到 newConnection 后,直接把 socket 指针传给一个工作线程的槽函数。工作线程处理完数据后,在主线程把 socket 删了。结果工作线程还在用这个指针——野指针崩溃。后来我改用 moveToThread,让 socket 跟着工作线程走,问题才解决。

2.7 小结

QTcpSocket 和 QTcpServer 的 API 不难,难的是理解“数据是流过来的,不是整包倒过来的”。记住三条:

  • UI 线程只用非阻塞模式,信号驱动
  • readyRead 信号里一定要做分包处理
  • 跨线程用信号槽时,注意对象生命周期

把这些基础打牢,后面讲多线程并发服务器的时候,你才不会手忙脚乱。