在 VC 开发里同时搞客户端和服务器,核心答案就一句话:在一个工程里创建两条执行脉络,一个工作线程专职跑服务器的 accept 监听循环,主线程或另一个线程跑客户端的 connect 和收发逻辑,两者通过 Socket 接口互通消息。
很多人第一次接触这个需求,是在做局域网聊天工具或联机对战作业的时候,心里想的是“我既要模拟服务器,又要模拟客户端”,结果一运行发现界面卡死了,这背后的原因,比想象中简单得多。
核心难点:一个进程里跑两头,卡死是必然的
先别急着写代码,我们得先理解为什么“这么难,VC 开发中最常用的是 Winsock 库,服务器端的 accept() 函数是一个阻塞调用,意思是程序执行到这里会停住,等有人连接,问题就出在这:如果把这个等待放在主线程(也就是承载窗口消息循环的线程),一旦阻塞,窗口就既不能拖动也不能重绘,看起来就像死机了一样。
行业共识认为,解决这个问题的思路不是让主线程去等,而是让专业的事在专业的线程里干,一个线程专门“蹲守”服务器端口,另一个线程负责“主动上门”连接对方,这就是单进程内实现双角色的基本盘。
具体做法分三步走:
- 启动时初始化 Winsock 库,调用
WSAStartup。 - 创建一个服务器工作线程,执行
bind、listen、accept。 - 在主线程里创建 Socket,执行
connect去连接自己刚开的服务器。
听起来简单,但细枝末节藏着不少坑。
搭建服务器端的骨架:把监听循环关进线程里
假设我们要写一个本地测试工具,服务器监听在 0.0.1 的 7788 端口,客户端连同一个地址,服务器线程的代码骨架大致长这样:
// 服务器线程入口
UINT ServerThreadProc(LPVOID pParam)
{
SOCKET listenSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
SOCKADDR_IN addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
addr.sin_port = htons(7788);
bind(listenSocket, (SOCKADDR)&addr, sizeof(addr));
listen(listenSocket, 5);
// 主循环:一直等着接收连接
while (!bStopFlag)
{
SOCKET clientSocket = accept(listenSocket, NULL, NULL);
// 每来一个连接,开临时线程处理收发
AfxBeginThread(ClientHandlerThread, &clientSocket);
}
closesocket(listenSocket);
return 0;
}
注意几个关键点:bStopFlag 是线程退出标志,程序关闭时要把它置真再关闭监听套接字,不然 accept 会永远卡住,线程无法退出,每次 accept 返回一个套接字,这个套接字是跟客户端单独通话的通道,跟监听套接字不是一回事。
先解决阻塞:用套接字选项避免永久卡死
accept 阻塞没法避免,但可以加超时,调用 setsockopt 把接收超时设为 500 毫秒:
int timeOut = 500; setsockopt(listenSocket, SOL_SOCKET, SO_RCVTIMEO, (char)&timeOut, sizeof(timeOut));
这样 accept 每 500 毫秒醒一次,检查退出标志,然后继续等待,这是多线程服务器温和退出的常用技巧。
客户端连接与消息收发:扮演“主动方”
客户端比服务器简单,核心就三个动作:建 Socket、connect、send / recv,我们把这三件事放在主线程里做一个“连接并发送消息”的按钮事件:
void CMyDlg::OnBtnSend()
{
SOCKET client = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
SOCKADDR_IN serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
serverAddr.sin_port = htons(7788);
connect(client, (SOCKADDR)&serverAddr, sizeof(serverAddr));
const char msg = "Hello, Server!";
send(client, msg, strlen(msg), 0);
closesocket(client);
}
这里有个隐蔽的问题:如果服务器还没起来,connect 会返回失败,实际开发中要在 connect 前后加上下 2 秒的重试逻辑,或者让 UI 界面实时提示连接状态,目前的代码只适合服务器已经跑起来的情况。
客户端怎么接收服务器主动推送的消息
如果你需要客户端常驻接收消息(比如聊天软件),那就不能在按钮事件里做 recv,解决办法是再起一个接收线程,循环调用 recv。监听用一个线程,发送用其它逻辑,接收又用一个线程,这套模型能覆盖 90% 的桌面端通信场景。
同时跑通的关键:协调好线程与窗口消息
代码写完了,但你有很大概率遇到一个新问题:服务器线程收到的消息,怎么显示到窗口的编辑框里?直接在工作线程里调用
SetDlgItemText 是个不安全的做法,可能导致界面刷新出问题。
稳妥的写法是把网络消息包装成自定义消息,投递给主窗口,在 PostMessage 的参数里塞入消息内容:
#define WM_NET_MSG (WM_USER + 100) // 服务器线程收到数据后 ::PostMessage(hMainWnd, WM_NET_MSG, (WPARAM)new CString(buffer), 0);
在主窗口的 ON_MESSAGE(WM_NET_MSG, OnNetMsg) 响应函数里,取出字符串并更新 UI,这一招是跨线程界面的“标准动作”,也是旁听客户端机房调试时最常见的沟通方式。
用事件对象实现优雅退出
除了消息,还能用 CreateEvent 控制线程启停,服务器线程等待 accept 返回时,主线程可以 SetEvent 通知它退出,不过实操中,关闭监听套接字本身就能让 accept 立即返回 SOCKET_ERROR,这是最简单的叫停方式。
实战调试:验证“两边都活着”的三种手段
代码写完怎么证明“同时进行”?靠眼睛盯着两个窗口不够,业内专家指出,最可靠的验证方式是抓包或打印日志。
- 在服务器线程
accept成功后,用日志输出一句“来自某端口的连接已建立”。 - 在客户端
connect返回成功后,输出“连接服务器成功”。 - 用
netstat -ano | findstr 7788查看端口状态,如果显示“LISTENING”而且没有报错,说明服务器是活着的同时客户端也连上了。
如果两边都连上但收发数据失败,优先检查字节序。htons 和 htonl 是必须用的小明第一次写的时候没做转换,端口号 7788 变成了 2664,直接连不上。
客户端和服务器同时跑的常见坑:三个高频故障
端口被占用导致 bind 失败
循环测试时上次的程序没退出干净,端口还被占着。bind 返回 WSAEADDRINUSE,解决方法是 setsockopt 设置 SO_REUSEADDR 选项,或者把端口改成高位段,30000 以上。
服务器先启动 vs 客户端先启动
严格场景下,服务器应先于客户端就绪,但如果客户端先启动,connect 会直接失败,你可以在
connect 失败后加 3 次重试,每次间隔 300 毫秒。
两个函数同时操纵同一个 Socket
收发数据用同一个 SOCKET 句柄时,要小心两个线程同时调用 send,虽然 Winsock 本身是线程安全的,但频繁交叉使用会导致数据粘包或乱序,稳妥的做法是每路数据加一个临界区,简单粗暴但也稳如老狗。
进阶思考:为什么不用 CAsyncSocket 或者网络库
有的朋友查到 MFC 里封装好的 CAsyncSocket,图它不用手写线程就能收消息,确实,窗口收到 FD_READ 通知自动回调,但它有个限制:消息循环被阻塞时,回调不触发,这会导致一个现象:代码在执行耗时运算时,网络数据收不到,所以有大流量传输需求,还是得回到多线程 Socket 的老路。
也可以引入 libevent 或 Boost.Asio,它们用事件驱动代替线程阻塞,但为了简单作业,标准库 Winsock 已经足够说明问题。一个线程管监听 + 一个线程管收发 + 主线程管界面,这三板斧能应对 90% 的教学项目水平。
常见问题解答
VC++ 客户端和服务器同时开,为什么服务器连接不上?
检查防火墙是否拦截了 TestApp.exe,尤其是 Windows Defender 的专用网络入站规则,本地回环测试用 0.0.1 一般能绕过防火墙,但是如果你填的是本机局域网 IP(192.168.x.x),Windows 防火墙就会拦截连接。
为什么 send 返回成功但服务器没收到?
send 成功只代表数据复制到了系统缓冲区,不代表对方 recv 已经拿到,也可能是数据粘包你连续发了两次小数据,服务器一次 recv 全部读走了,正确做法是协议上固定包长度,或者用 n 做分隔符。
调试时窗口卡死,但程序没崩溃,是怎么回事?
十有八九是你在主线程调用了阻塞版 recv,一旦服务器没数据发过来,主线程就永远卡在等待上,界面操作就全停了,把这个 recv 挪到工作线程里,立刻就好。
把服务器逻辑从主线程中剥离出来,是客户端和服务器能在一个进程里舒服地同时工作的根本前提。 只要保证界面线程不碰网络阻塞函数,数据走自定义消息回传,你就能轻松驾驭这套双角色联调模式。
首发原创文章,作者:王坚,如若转载,请注明出处:https://idctop.com/article/700398.html





