用 C 写了个 Windows 聊天室,踩了几个多线程和控制台的坑
写这个东西的初衷很简单:想在不依赖任何第三方库的前提下,用纯 WinSock2 搭一个能在 Windows 上跑起来的聊天室。代码量控制在服务端 400 行、客户端 200 行以内------够小,读完就能跑;但也没小到只能 demo 一下。
最终版本跑起来之后,回过去看改动历史,发现真正占时间的不是 WinSock 初始化那些样板代码,而是几个在写之前没想到的并发和控制台渲染问题。
线程模型
整个程序是经典的「主线程 accept + 每连接一个工作线程」模型:
#mermaid-svg-pU1zazSFFHACyoCQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pU1zazSFFHACyoCQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pU1zazSFFHACyoCQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pU1zazSFFHACyoCQ .error-icon{fill:#552222;}#mermaid-svg-pU1zazSFFHACyoCQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pU1zazSFFHACyoCQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pU1zazSFFHACyoCQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pU1zazSFFHACyoCQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pU1zazSFFHACyoCQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pU1zazSFFHACyoCQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pU1zazSFFHACyoCQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pU1zazSFFHACyoCQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pU1zazSFFHACyoCQ .marker.cross{stroke:#333333;}#mermaid-svg-pU1zazSFFHACyoCQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pU1zazSFFHACyoCQ p{margin:0;}#mermaid-svg-pU1zazSFFHACyoCQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pU1zazSFFHACyoCQ .cluster-label text{fill:#333;}#mermaid-svg-pU1zazSFFHACyoCQ .cluster-label span{color:#333;}#mermaid-svg-pU1zazSFFHACyoCQ .cluster-label span p{background-color:transparent;}#mermaid-svg-pU1zazSFFHACyoCQ .label text,#mermaid-svg-pU1zazSFFHACyoCQ span{fill:#333;color:#333;}#mermaid-svg-pU1zazSFFHACyoCQ .node rect,#mermaid-svg-pU1zazSFFHACyoCQ .node circle,#mermaid-svg-pU1zazSFFHACyoCQ .node ellipse,#mermaid-svg-pU1zazSFFHACyoCQ .node polygon,#mermaid-svg-pU1zazSFFHACyoCQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pU1zazSFFHACyoCQ .rough-node .label text,#mermaid-svg-pU1zazSFFHACyoCQ .node .label text,#mermaid-svg-pU1zazSFFHACyoCQ .image-shape .label,#mermaid-svg-pU1zazSFFHACyoCQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-pU1zazSFFHACyoCQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pU1zazSFFHACyoCQ .rough-node .label,#mermaid-svg-pU1zazSFFHACyoCQ .node .label,#mermaid-svg-pU1zazSFFHACyoCQ .image-shape .label,#mermaid-svg-pU1zazSFFHACyoCQ .icon-shape .label{text-align:center;}#mermaid-svg-pU1zazSFFHACyoCQ .node.clickable{cursor:pointer;}#mermaid-svg-pU1zazSFFHACyoCQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pU1zazSFFHACyoCQ .arrowheadPath{fill:#333333;}#mermaid-svg-pU1zazSFFHACyoCQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pU1zazSFFHACyoCQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pU1zazSFFHACyoCQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pU1zazSFFHACyoCQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pU1zazSFFHACyoCQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pU1zazSFFHACyoCQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pU1zazSFFHACyoCQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pU1zazSFFHACyoCQ .cluster text{fill:#333;}#mermaid-svg-pU1zazSFFHACyoCQ .cluster span{color:#333;}#mermaid-svg-pU1zazSFFHACyoCQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pU1zazSFFHACyoCQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pU1zazSFFHACyoCQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-pU1zazSFFHACyoCQ .icon-shape,#mermaid-svg-pU1zazSFFHACyoCQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pU1zazSFFHACyoCQ .icon-shape p,#mermaid-svg-pU1zazSFFHACyoCQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pU1zazSFFHACyoCQ .icon-shape .label rect,#mermaid-svg-pU1zazSFFHACyoCQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pU1zazSFFHACyoCQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pU1zazSFFHACyoCQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pU1zazSFFHACyoCQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 客户端 client.c
fgets 阻塞读输入
recv 接收
打印 prompt
main 主线程
send 到服务端
receive_thread
独立线程
g_console_cs
同一把锁
服务端 server.c
accept 新连接
注册/聊天时访问
server_log
server_log
main 主线程
创建 ClientNode 加入链表
CreateThread → client_handler
while recv 循环
online_clients
临界区保护
g_console_cs
全局锁
服务端维护一张全局链表 online_clients,每个工作线程在注册、发消息、离开三个节点上持有 cs(CRITICAL_SECTION)访问这张表。另外还有一把独立的 g_console_cs 专门保护 printf------多个线程同时往控制台打日志会交织在一起,这个问题比我想的更早出现。
客户端的模型是反过来的:主线程 fgets 阻塞读用户输入,一个独立的 receive_thread 负责 recv 接收服务端消息。两边都要往 stdout 写东西,所以共用一把 g_console_cs。
这个模型在 demo 阶段很顺畅,直到我开始把它当一个真正的聊天室来用。
广播阻塞:临界区里不能做 send
写完第一版之后发现一个很诡异的现象:只要有一个客户端网络卡顿或者挂起(比如浏览器里某个 tab 还没关完),整个聊天室就像被点了暂停,其他人发的消息都石沉大海。
根因其实就是一行代码的位置不对。
最早的 broadcast 是这样写的:
c
EnterCriticalSection(&cs);
ClientNode *curr = online_clients;
while (curr) {
send(curr->sock, msg, ...); // ← 这里面可能卡很久
curr = curr->next;
}
LeaveCriticalSection(&cs);
问题很明显:send 不是一个可以忽略耗时的操作。对端的 TCP 发送窗口如果满了(比如对方网络断了但还没断开 socket),send 会阻塞直到有缓冲区空出来。这段时间里 cs 一直被持有,其他线程进来想查一下在线用户表、或者有人要注册、或者有人要离开,统统卡在 EnterCriticalSection 那里。一个人慢,所有人等。
解法是把「遍历链表」和「发送数据」拆成两步,中间释放锁:
各客户端 socket online_clients client_handler 线程 各客户端 socket online_clients client_handler 线程 #mermaid-svg-F15jCayRKsoZHArU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-F15jCayRKsoZHArU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-F15jCayRKsoZHArU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-F15jCayRKsoZHArU .error-icon{fill:#552222;}#mermaid-svg-F15jCayRKsoZHArU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-F15jCayRKsoZHArU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-F15jCayRKsoZHArU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-F15jCayRKsoZHArU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-F15jCayRKsoZHArU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-F15jCayRKsoZHArU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-F15jCayRKsoZHArU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-F15jCayRKsoZHArU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-F15jCayRKsoZHArU .marker.cross{stroke:#333333;}#mermaid-svg-F15jCayRKsoZHArU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-F15jCayRKsoZHArU p{margin:0;}#mermaid-svg-F15jCayRKsoZHArU .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-F15jCayRKsoZHArU text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-F15jCayRKsoZHArU .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-F15jCayRKsoZHArU .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-F15jCayRKsoZHArU .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-F15jCayRKsoZHArU .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-F15jCayRKsoZHArU #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-F15jCayRKsoZHArU .sequenceNumber{fill:white;}#mermaid-svg-F15jCayRKsoZHArU #sequencenumber{fill:#333;}#mermaid-svg-F15jCayRKsoZHArU #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-F15jCayRKsoZHArU .messageText{fill:#333;stroke:none;}#mermaid-svg-F15jCayRKsoZHArU .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-F15jCayRKsoZHArU .labelText,#mermaid-svg-F15jCayRKsoZHArU .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-F15jCayRKsoZHArU .loopText,#mermaid-svg-F15jCayRKsoZHArU .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-F15jCayRKsoZHArU .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-F15jCayRKsoZHArU .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-F15jCayRKsoZHArU .noteText,#mermaid-svg-F15jCayRKsoZHArU .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-F15jCayRKsoZHArU .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-F15jCayRKsoZHArU .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-F15jCayRKsoZHArU .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-F15jCayRKsoZHArU .actorPopupMenu{position:absolute;}#mermaid-svg-F15jCayRKsoZHArU .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-F15jCayRKsoZHArU .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-F15jCayRKsoZHArU .actor-man circle,#mermaid-svg-F15jCayRKsoZHArU line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-F15jCayRKsoZHArU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 进入临界区snapshot_online_locked()把 sock 拷贝到数组释放临界区broadcast_via_snapshot()在锁外遍历数组 send
snapshot_online_locked 做的事情很朴素:在持有锁的几微秒内,把链表上每个节点的 socket 和用户名拷贝到一个 SOCKET[] 和 char*[] 数组里,然后就退出临界区。真正的 send 循环在锁外跑------某一个 socket 卡住,其他人的 send 不受影响,更关键的是 cs 不会被长期占用。
代价是多了一次 malloc/free。对一个百级客户端的聊天室来说,这完全可以接受,换回来的是整个程序在任何网络条件下都不会出现"一个人拖死所有人"的情况。
代码里还有一个细节:snapshot 时 sender 自己要跳过(if curr->sock != sender),不然就会把自己发的消息又发回给自己。
控制台渲染:异步输出和阻塞输入的竞争
客户端那边的问题更隐蔽一些。main 线程在 fgets 上阻塞,receive_thread 在 recv 上阻塞。两个线程都要往控制台写东西,冲突点在于:当 receive_thread 收到一条消息准备打印时,用户可能正卡在 fgets 上,屏幕上正显示着 [Anjuxi]> 后面跟着用户已经打了一半但还没回车的文字。
如果只是简单地 printf(message),屏幕会变成这样:
[Anjuxi]> 正在输入一
[Bob]: 大家好啊!
些文字还没敲完
Bob 的消息插在了用户打字的中间。这在一个命令行界面里是完全不可接受的。
最直接的修法是加锁,但光加锁不够------fgets 本身是阻塞调用,不能放进临界区(放进临界区的话,只要用户不敲回车,整个客户端就死锁了)。所以实际的设计是:
主线程只在"有东西要输出"的时候抢锁 ,而输入阶段 fgets 完全不持锁。关键在于 receive_thread 收到消息后的输出路径是三步原子化的:
c
EnterCriticalSection(&g_console_cs);
printf("\r%-80s\r", ""); // 第一步:把当前正在输入的那一行清空
fputs(buffer, stdout); // 第二步:打印收到的消息
draw_prompt_locked(); // 第三步:重绘 prompt
LeaveCriticalSection(&g_console_cs);
第一步的 \r%-80s\r 是个小技巧:\r 把光标移到行首,输出 80 个空格把这一行的任何内容都覆盖掉,再一个 \r 把光标移回来。这样不管用户那一行打了多少字,瞬间就被擦干净了。
第二步打印消息,第三步马上重绘 prompt。因为三步在同一把锁里,主线程和接收线程不会互相打断,屏幕上永远不会出现消息插到打字中间的情况。
至于 prompt 显示什么,取决于有没有完成注册------注册成功后 g_username 会被填上值,prompt 就从 (未注册)> 变成 [Anjuxi]> 。
注册状态:乐观假设 + 失败回滚
这里有个跨线程状态同步的小问题。客户端发送 REGISTER:Anjuxi 之后,服务端可能返回 REGISTER_OK 也可能返回 REGISTER_FAIL。客户端需要在拿到服务端回复之后才能确定注册是否成功------但回复是异步的,有可能在用户继续打字的时候才到。
最直接的做法是:等服务端确认之后再设 g_username。但这样的话,注册成功之前客户端就要一直保持"未注册"状态,prompt 也不会变。我最终选了一个更激进的策略:
先假设注册成功 。用户在 main 线程里一发 REGISTER: 命令,就立刻把 g_username 设上,prompt 马上变。如果服务端回复了 REGISTER_FAIL,由 receive_thread 检测到之后把 g_username 清空,prompt 自动退回到 (未注册)> 。
这个策略的好处是用户体验是实时的------命令发出去的瞬间 prompt 就变了,不需要等一个 round trip。而失败的情况由接收线程兜底,两种状态的同步通过 g_console_cs 保证,不会出现 prompt 和实际状态不一致的窗口。
代价是有一个极短的"乐观假设窗口期"------假设注册已经成功但实际上服务端还没确认的那几十毫秒里,如果用户快速连续输入,消息还是会被当作未注册状态被服务端拒绝。但这个窗口太短暂,在正常使用中几乎不会被感知到。
剩下的坑
写完之后回看,还有几个可以继续完善的点:
粘包问题没有处理 。现在的协议是基于换行符分割的,recv 如果一次收了两条消息或者半条消息,会直接丢进 buffer 里处理。在本地网络上跑没问题,但如果要跨公网、要能扛更大的消息量,就需要引入一个消息边界协议(比如固定长度头 + payload)。
用户名唯一性现在靠内存表。用户下线之后名字立刻释放,可以被别人重新注册。这在 demo 里够用,但如果要做"名字永久占用",就需要一个持久化存储。
没有鉴权。任何人连上就能发消息,想加的话得先把协议做扎实。
这两个文件加起来不到 600 行,-Wall -Wextra 编过去零 warning。跑起来之后最让我满意的不是功能都 work,而是那几个在设计阶段没预料到的问题都用了比较干净的方式解决------快照广播、三步原子化渲染、乐观注册,每一个都是在实际跑的时候踩出来的。如果只是对着教材写一个"能跑的聊天室",这些问题根本碰不到。
⚠️代码在 GitHub:https://github.com/anjuxi/simple_chat_room