资讯动态

多线程UDP聊天室:Socket编程与线程安全实战指南

发布时间:2026/10/22 9:04:21 来源:尧图企业网站定制
做网络编程的人大概率都有过这种经历单线程收发一切正常日志刷得飞快可一旦你想让服务器同时处理几十个人的消息加入上下线、广播、心跳这些逻辑程序就开始不稳定偶尔卡死、偶尔崩溃、甚至悄悄丢包。UDP Socket 系列写到第三篇前两篇把基础的收发和超时重传聊完了这篇直接上硬菜多线程聊天室重点围绕 UDP Socket 编程、多线程调度、线程安全这三件事展开。这篇适合两类读者一类是刚学完 UDP 基础编程、想往并发方向迈一步的同学另一类是已经写过一些网络程序但被“加了线程就崩”“偶尔丢消息”折磨过的人。我会从模型选型、架构设计、核心代码实现一直讲到排查技巧和性能调优尽量让看完的人能直接照着搭出一个能跑的框架。1. 为什么是 UDP 加多线程场景与技术选型1.1 UDP 的“无连接”到底意味着什么先说一个很多新手容易绕进去的问题为什么聊天室要用 UDP而不是老老实实上 TCPTCP 有连接状态有三次握手、四次挥手有可靠传输和流量控制这些特性在文件传输、网页请求等场景非常宝贵。但聊天室这种东西本质上是“一群人同时在说话”消息很短实时性要求高而且允许个别消息丢失后用户自己重说一遍。UDP 不需要维护连接表每个报文自带完整的目标地址服务器收到一个数据报就能直接处理。用户上线不需要握手下线也不需要告别天然贴合“一堆人随时说话”的场景。代价也很明确UDP 不可靠可能丢包、乱序、重复而且没有拥塞控制。所以在设计聊天室时你不能假设“发出去的消息一定到达”必须在应用层做兜底。多线程在这里要解决的核心问题不是可靠传输而是“服务器如何同时处理多个客户端的消息而不互相阻塞”。1.2 单线程聊天室的瓶颈在哪里如果你用最简单的方式写 UDP 聊天室通常是一个while循环里调用recvfrom收到一条消息就同步解析、查在线用户表、然后逐个sendto转发。这在客户端数量少、消息频率低的时候没问题但一旦消息量上来问题立刻暴露recvfrom之后的处理是串行的解析一条消息、遍历用户表、连续调用多次sendto这些工作全部占用同一个线程。如果某个目标地址的网络状况差或者系统调用本身因为调度原因变慢后面的消息只能在缓冲区里等着。内核 UDP 接收缓冲区是有限的当处理速度跟不上到达速度缓冲区满了以后内核会直接丢弃数据包。你甚至不知道丢了哪些包。一句话总结单线程把“网络接收”和“业务处理”串行化了接收速度被处理逻辑拖累这才是高负载下丢包的真正原因。多线程的目的就是把“收包”和“干活”拆开。1.3 三种并发模型对比为什么选“收包线程加工作线程池”站在架构角度UDP 服务端的并发模型大致有三种模型优势劣势适合场景每客户端一个线程代码直观每个客户端独占处理路径UDP 无连接概念线程数会随客户端数量膨胀最终耗尽资源技术上最不推荐单线程事件循环select/poll/epoll高并发资源占用低业务处理仍在一个线程里处理太久会阻塞所有事件回调嵌套难维护海量连接、短消息场景收包线程 工作线程池收包和处理解耦能利用多核并行代码结构清晰需要自己处理线程安全问题几百人规模的聊天室非常适合学习与实战我选择第三种模型原因很实在聊天室是典型的“接收快、处理慢”场景。收包线程要把recvfrom干的事情做到极致纯粹——收进来塞进队列立刻返回继续收。工作线程池里的每个线程负责从队列里取消息、解析、转发多个工作线程可以跑在多个 CPU 核心上真正实现并行处理。有人可能会说epoll 单线程模型也能做到高吞吐为什么非要多线程因为在这个系列的教学语境下线程安全问题本身就是我们要攻克的重点。而且从工程上讲中规模聊天室用线程池代码写起来直观、好调试出问题也容易排查。等到用户量真的大到线程池撑不住时再考虑事件驱动模型也来得及。2. 整体架构设计与线程分工2.1 服务器端线程模型长什么样服务器端的线程结构非常简单我按角色分成三类一个接收线程专责recvfrom。它唯一的任务是拿到数据报后快速放入消息队列不做任何业务解析。N 个工作线程从消息队列里取数据报解析协议更新在线用户表执行广播或单播转发。N 通常取 CPU 核心数比如 4 核机器就开 4 个工作线程。一个心跳线程每隔几秒钟扫描一次在线用户表把超时未活跃的用户踢下线防止用户表里堆积僵尸节点。这种分工的核心思想就是把“与内核交互”和“与业务数据交互”彻底分开。接收线程玩命地从内核拿数据业务线程专心处理数据结构互不干扰。客户端那边逻辑更简单一个线程负责从标准输入读用户输入并发送另一个线程负责阻塞在recvfrom上收到消息就打印到屏幕。你不开接收线程的话客户端一边等用户打字一边收消息一定会卡死。2.2 共享数据结构在线用户表与消息队列多线程程序里比代码更重要的是数据结构。我用了两个最基础的结构在线用户表一个单向链表每个节点存客户端的struct sockaddr_in地址、用户名、最后活跃时间。为什么用链表因为聊天室用户是动态增删的链表插入删除都是O(1)遍历是O(n)几百人规模完全够用。把链表换成哈希表是后话第一版先用最简单的。消息队列一个先进先出的单向链表接收线程往尾部塞消息节点工作线程从头部取。这里必须用“生产者-消费者”模型配合互斥锁和条件变量而不是用无锁队列——无锁队列的 ABA 问题和内存管理对新手来说太容易踩坑性能收益在低负载下也体现不出来。这两个结构就是整个程序里最核心的共享资源也是线程安全问题的“重灾区”。2.3 最小报文协议第一版能跑就行有人写聊天室喜欢上来就定义二进制协议、消息编号、校验和我建议第一版别这么干。先上最朴素的文本协议用竖线分隔字段三到四个类型足够LOGIN|alice CHAT|大家好 LOGOUT|alice PING服务器收到LOGIN|alice就把alice和来源地址加入用户表然后广播一条“alice 上线了”收到CHAT|大家好就遍历用户表给除发送者之外的所有人转发收到LOGOUT|alice或者心跳超时就把节点删掉。文本协议的好处是可以用telnet或者nc -u直接调试出问题一眼就能看出来。后续真要上生产再替换成带消息序号和类型字段的二进制协议也不迟。别在第一版把复杂度堆上去那时候你根本分不清是协议问题还是线程问题。3. 核心代码实现从 socket 到多线程调度3.1 建立 socket 与启动线程池先初始化 UDP socket。这个部分和前两篇的区别不大但有几个细节值得注意。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include pthread.h #include netinet/in.h #include sys/socket.h #include arpa/inet.h #include time.h #define PORT 8888 #define MAX_MSG 1024 #define MAX_NAME 32 #define WORKER_NUM 4 #define LOGIN LOGIN #define CHAT CHAT #define LOGOUT LOGOUT static int server_fd; static struct sockaddr_in server_addr; int main(void) { int opt 1; server_fd socket(AF_INET, SOCK_DGRAM, 0); if (server_fd 0) { perror(socket); exit(EXIT_FAILURE); } // 允许端口复用调试重启时不用等 setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(PORT); if (bind(server_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); close(server_fd); exit(EXIT_FAILURE); } pthread_t recv_tid; pthread_t worker_tids[WORKER_NUM]; pthread_create(recv_tid, NULL, recv_thread, NULL); for (int i 0; i WORKER_NUM; i) { pthread_create(worker_tids[i], NULL, work_thread, NULL); } // 心跳线程 pthread_t heartbeat_tid; pthread_create(heartbeat_tid, NULL, heartbeat_thread, NULL); pthread_join(recv_tid, NULL); return 0; }这里要单独说下SO_REUSEADDR。虽然 UDP 没有 TCP 那种TIME_WAIT状态但在多实例调试、或者同一端口被多个进程绑定的场景下这个选项能省掉很多麻烦。另外server_fd是一个全局变量但它本质上在启动后被所有线程共享只读所以不需要加锁保护。记住一个原则锁是保护“会被修改的共享数据”不是保护所有全局变量。3.2 接收线程持续 recvfrom 与入队接收线程是整个系统的入口它的代码必须足够简洁。核心逻辑就是循环里做四件事接收、分配节点、上锁入队、唤醒消费者。typedef struct user_node { struct sockaddr_in addr; char name[MAX_NAME]; time_t last_active; struct user_node *next; } user_node; typedef struct msg_node { struct sockaddr_in from; char data[MAX_MSG]; size_t len; struct msg_node *next; } msg_node; static msg_node *msg_queue_head NULL; static msg_node *msg_queue_tail NULL; static pthread_mutex_t queue_lock PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t queue_cond PTHREAD_COND_INITIALIZER; void *recv_thread(void *arg) { char buf[MAX_MSG]; struct sockaddr_in from; socklen_t addrlen sizeof(from); while (1) { // 每次循环必须重置 addrlen否则地址解析可能出错 addrlen sizeof(from); ssize_t n recvfrom(server_fd, buf, sizeof(buf) - 1, 0, (struct sockaddr *)from, addrlen); if (n 0) { perror(recvfrom); continue; } buf[n] \0; msg_node *node malloc(sizeof(msg_node)); if (!node) continue; memset(node, 0, sizeof(msg_node)); node-from from; memcpy(node-data, buf, n 1); node-len n; pthread_mutex_lock(queue_lock); if (msg_queue_tail) { msg_queue_tail-next node; } else { msg_queue_head node; } msg_queue_tail node; pthread_cond_signal(queue_cond); pthread_mutex_unlock(queue_lock); } return NULL; }注意最后这段加锁临界区很小只有“把节点接到链表尾部”这一个操作。有人会为了省一点锁开销把内存分配挪到加锁之后那反而会让临界区变大拖慢整个队列。正确的做法是把内存分配、数据拷贝这些耗时操作全部放在锁外完成锁内只做指针操作。3.3 工作线程出队、解析、转发工作线程的逻辑看起来更复杂但好消息是它处理完一个消息后不会阻塞接收路径。核心代码分三步从队列取节点、解析协议、根据类型执行动作。static pthread_mutex_t user_lock PTHREAD_MUTEX_INITIALIZER; static user_node *user_list NULL; void *work_thread(void *arg) { while (1) { pthread_mutex_lock(queue_lock); while (!msg_queue_head) { pthread_cond_wait(queue_cond, queue_lock); } msg_node *node msg_queue_head; msg_queue_head node-next; if (!msg_queue_head) { msg_queue_tail NULL; } pthread_mutex_unlock(queue_lock); handle_message(node); free(node); } return NULL; } void handle_message(msg_node *node) { char *saveptr NULL; char *type strtok_r(node-data, |, saveptr); char *arg strtok_r(NULL, |, saveptr); if (!type) return; if (strcmp(type, LOGIN) 0 arg) { user_add(node-from, arg); broadcast_system(%s online, arg); } else if (strcmp(type, CHAT) 0 arg) { char *name find_name(node-from); if (name) { broadcast(name, arg); } } else if (strcmp(type, LOGOUT) 0 arg) { user_remove(arg); broadcast_system(%s offline, arg); } }这里的strtok_r用了可重入版本这是个细节。普通strtok内部维护静态指针多个线程同时调用会互相覆盖导致解析错乱strtok_r把状态保存在调用方提供的指针里才是线程安全的。很多新手在这里踩坑解析结果偶尔对偶尔错就是因为它。转发消息时多个工作线程会用同一个server_fd调用sendto。UDP 的sendto是原子系统调用多个线程同时发送不会破坏数据所以这里不需要额外加锁。但要注意如果你希望“同一个用户发出的消息按顺序到达”那就得在发送端做控制。我的经验是聊天室场景里消息顺序要求没那么严格UDP 本身就可能乱序所以第一版不做顺序保证把复杂度留给后续再说。3.4 用户上下线与心跳剔除用户表操作函数必须保护起来因为多个工作线程可能同时执行user_add和user_remove。我统一用user_lock互斥锁保护。void user_add(struct sockaddr_in *addr, const char *name) { pthread_mutex_lock(user_lock); user_node *cur user_list; while (cur) { if (strcmp(cur-name, name) 0) { pthread_mutex_unlock(user_lock); return; // 重名拒绝上线 } cur cur-next; } user_node *node calloc(1, sizeof(user_node)); node-addr *addr; snprintf(node-name, MAX_NAME, %s, name); node-last_active time(NULL); node-next user_list; user_list node; pthread_mutex_unlock(user_lock); }这里头插法把新用户插到链表头部实现简单遍历的时候也能优先访问最近上线的用户。心跳线程要做的就是每隔几秒拿一次当前时间遍历用户表把所有last_active超过 30 秒的节点清掉。void *heartbeat_thread(void *arg) { while (1) { sleep(10); time_t now time(NULL); pthread_mutex_lock(user_lock); user_node **pp user_list; while (*pp) { user_node *cur *pp; if (now - cur-last_active 30) { *pp cur-next; broadcast_system(%s timeout, cur-name); free(cur); } else { pp cur-next; } } pthread_mutex_unlock(user_lock); } return NULL; }心跳超时的时间阈值要考虑延迟。我设 30 秒是因为客户端的心跳间隔是 10 秒允许连续丢两次心跳才判定下线这样误踢概率很低。这里有个小技巧心跳线程不要自己去调用sendto广播而是把“下线的系统消息”也当作普通消息塞进队列交给工作线程去发。否则心跳线程也得处理发送逻辑临界区会变大锁持有时间变长。4. 线程安全本质锁、条件变量和临界区4.1 哪些共享数据必须加锁哪些不用很多初学者一听说线程安全就觉得所有变量都要加锁。真不是这样。我整理了一个判断标准只有满足“多个线程同时访问”且“至少有一个线程在写”的内存才需要同步保护。按这个标准看聊天室程序数据线程访问情况是否需要锁消息队列头尾指针接收线程写工作线程读必须互斥锁保护在线用户表链表头多个工作线程读写心跳线程写必须互斥锁 / 读写锁保护server_fd所有线程只读调用 sendto/recvfrom不需要单个消息节点的数据只有持有该节点的工作线程访问不需要全局统计变量如消息计数多个工作线程同时写必须原子操作或加锁所以本质上整个程序需要守护的只有两个对象消息队列和用户表。把这两个结构操作封装成函数所有访问都走函数入口线程安全就能管起来。最忌讳的是业务代码里到处散落pthread_mutex_lock今天这里锁一下明天那里锁一下锁的边界混乱最后一定会出死锁。4.2 用互斥锁保护消息队列条件变量的正确用法消息队列用互斥锁加条件变量的组合这是生产者-消费者模型的标准写法。但“会用”和“用对”是两回事最容易错的点有三个。第一pthread_cond_wait必须传入一个已加锁的互斥锁。它会原子地释放锁、阻塞当前线程被唤醒时线程会重新获取锁。这个设计是为了避免“条件满足”和“线程阻塞”之间的竞态。你如果忘了先加锁就调wait程序会直接未定义行为。第二等待条件要用while循环而不是if。pthread_mutex_lock(queue_lock); while (msg_queue_head NULL) { pthread_cond_wait(queue_cond, queue_lock); }为什么是while因为有两个原因一是条件变量可能被“虚假唤醒”这是 POSIX 允许的行为即使没有信号也可能返回二是多个工作线程都在等待时如果只发送一个signal被唤醒的线程拿到锁却发现队列还是空的那就得继续等。用while重新检查条件能把这两种情况都兜住。第三区分pthread_cond_signal和pthread_cond_broadcast。signal只唤醒一个等待线程适合单生产者单消费者场景。我在设计里只有一个接收线程队列入队时用signal就够了唤醒任何一个工作线程都能处理。但如果将来有多个接收线程或者你希望所有等待线程都醒过来竞争任务就得用broadcast。从实践角度说在确定单生产者时signal的调度开销更小。4.3 在线用户表的保护与读写锁选型用户表和消息队列不同它的操作模式是“读多写少”。工作线程转发消息时要遍历用户表给所有在线用户发送这是高频读操作用户上线、下线、心跳剔除是低频写操作。这种场景理论上适合pthread_rwlock_t读写锁多个读线程可以同时持有读锁写锁则独占。static pthread_rwlock_t user_rwlock PTHREAD_RWLOCK_INITIALIZER; void broadcast(const char *from, const char *content) { pthread_rwlock_rdlock(user_rwlock); user_node *cur user_list; while (cur) { // 给除 from 之外的所有人 sendto cur cur-next; } pthread_rwlock_unlock(user_rwlock); }但我必须说句实话读写锁不一定比普通互斥锁快。在临界区很短的情况下读写锁额外维护读计数器、处理读者优先或写者优先策略的开销可能超过互斥锁的简单争用。我的建议是如果用户表只有几百个人、遍历一次也就几微秒直接用普通互斥锁就行代码更简单出问题也好排查。等真的做了压测、证明锁竞争是性能瓶颈后再换读写锁不迟。4.4 避免死锁的几个实战原则多线程程序最恶心的故障就是死锁程序不崩溃、不报错就是卡住不动。触发死锁的必要条件是“两个线程互相持有对方需要的锁”。在聊天室这个项目里要避免死锁我给自己定了几条规矩。第一条尽量减少嵌套锁。拿锁的顺序要全局一致。比如工作线程先拿queue_lock取消息再拿user_lock更新用户表那所有线程都必须遵守“先队列锁、后用户锁”的顺序。如果某个地方不小心写成先拿user_lock再拿queue_lock两个线程就可能互相等死。第二条绝对不要在持锁的状态下调用可能阻塞的函数。比如持锁后去sleep、去sendto一个阻塞的 socket、去accept这些都是大忌。我前面特意把sendto广播放在释放锁之后就是为了避免锁内做 IO。锁的临界区里只处理内存数据这是铁律。第三条主动用工具检测。Linux 下valgrind --toolhelgrind能检测数据竞争和锁序问题编译时开-fsanitizethread也能在运行时捕获竞态。我每次写完多线程代码都会先跑一遍 ThreadSanitizer大部分数据竞争问题在测试阶段就暴露了。5. 常见问题与排查实录5.1 丢包率高居不下先分清楚是谁在丢有一次我自己写压力测试脚本模拟 200 个客户端同时发消息结果服务端能收到的消息不到一半。第一个念头是代码写错了但抓了半天没发现逻辑问题。后来用netstat -su一看RcvbufErrors和RcvDropped两个计数疯狂上涨才知道问题出在内核缓冲区不是应用层。UDP 协议栈里有一个接收缓冲区默认大小通常只有几百 KB。当应用来不及调用recvfrom取走数据缓冲区满了以后内核直接丢弃新到的数据报。解决办法有两条路一是调大 socket 缓冲区二是加快应用取包速度。int rcvbuf 4 * 1024 * 1024; setsockopt(server_fd, SOL_SOCKET, SO_RCVBUF, rcvbuf, sizeof(rcvbuf));注意SO_RCVBUF设置的值会被内核翻倍而且上限受net.core.rmem_max限制。你要是想设成 10MB得先修改内核参数否则实际生效的值可能远小于预期。加快取包的办法就是我一直在强调的接收线程只入队不做业务处理同时用recvmmsg批量接收减少系统调用次数。这套组合拳下来同样压力下丢包率能降一个量级。5.2 sendto 返回成功但对方就是没收到UDP 和 TCP 最大的区别就是sendto返回成功只代表数据报进入了本地内核协议栈至于能不能到达对方、对方有没有缓冲区接收应用层完全不知情。这种“看似成功实则丢失”的问题特别坑因为代码没问题网络环境却骗了你。排查顺序我建议这样固定下来先tcpdump -i any udp port 8888抓包确认报文确实从本机发出再到对端机器抓包确认报文到达网卡如果到了网卡但应用没收到那就是对端 socket 接收缓冲区的问题。还有一种可能是防火墙悄悄丢弃了 UDP 报文这个用iptables规则查一下就能看出来。真正的可靠传输必须应用层自己实现确认和重传这是另一个话题但做聊天室前心里要有数。5.3 多线程崩溃段错误和卡死的排查思路多线程程序崩溃有个特点不是每次都能复现用力压测才偶尔崩一次。这种间歇性崩溃十有八九是数据竞争导致的内存破坏比如两个线程同时在没有锁的情况下修改了链表的next指针链表结构被写坏下次遍历时访问到野指针直接段错误。我的排查习惯分三步走。第一步开 ThreadSanitizer 编译一遍在测试环境复现它能直接指出哪两行代码存在数据竞争。第二步上valgrind它能看到内存非法访问的完整堆栈帮定位野指针的位置。第三步如果崩得毫无规律就把全局变量和共享结构体的访问全部过一遍重点看是否所有写操作都在锁内。卡死又是另一回事。如果程序没有崩溃但消息不再被处理先用top看 CPU 占用再用gdb attach进进程执行thread apply all bt打印所有线程的调用栈。看到两个线程分别在等不同锁的时候死锁证据就实锤了。我最害怕的不是死锁而是“看起来没死锁但所有线程都阻塞在条件变量上”的假死那通常说明消费者线程没被正确唤醒或者队列指针被写坏了。5.4 性能测试多线程到底提升了什么多线程不是银弹加线程也不等于性能翻倍。我用同一个压力脚本做了对比测试单线程版在 100 个客户端同时发消息时就开始出现明显延迟和丢包换成了 1 个接收线程加 4 个工作线程后在同等负载下延迟降到原来的三分之一左右丢包几乎消失。但继续把工作线程加到 8 个、16 个吞吐量并没有线性上涨反而因为锁竞争加剧性能在 16 线程时略有下降。这里其实牵扯到一个规律当工作线程数量超过 CPU 核心数后线程切换和锁等待的开销会抵消并行收益。而且聊天室这种场景全局用户表锁是最大的串行点。所有的sendto都要遍历用户表获取地址虽然发送操作本身不加锁但查表操作在锁内读锁竞争就成了瓶颈。真要优化下一步是把用户表换成哈希桶每个桶一把锁把竞争分散掉。6. 踩坑回顾与后续扩展6.1 实操中反复踩过的几个细节写这个聊天室 Demo 的时候我在几个非常基础的细节上反复踩坑列出来给后来人提个醒。第一recvfrom的addrlen参数必须在每次调用前重置为sizeof(struct sockaddr_in)。这个值既是输入也是输出如果上次返回后把它改小了下次传进去内核可能因为缓冲区长度不够而拒绝拷贝地址导致from参数里拿到一堆垃圾值。我当时就是漏了重置结果用户表里存的地址全是错的消息转发全发到不知道什么地方去了。第二客户端读取标准输入时别用scanf配合固定数组会有缓冲区溢出的风险。用getline或者fgets都能更好控制长度。还有如果客户端写了两个线程一个读 stdin 一个收 UDP注意两个线程同时访问 stdout 打印消息时可能产生交错输出。解决方式很简单给打印操作加一把全局锁或者接受“偶尔一行乱序”的现实。第三别在信号处理函数里干复杂活。比如用户按 CtrlC 时你想释放资源直接在 SIGINT 的 handler 里去free链表、关 socket大概率会出事。信号处理函数应该只做设置标志位或者写self-pipe真正的清理逻辑交给主循环去做。6.2 从聊天室到 IM下一步往哪走这个多线程 UDP 聊天室只是起点。它证明了 UDP 可以承载一个中规模的实时通信系统也把“收包、处理、转发”的管线搭了出来。后续要扩展的方向其实很清晰把用户表从带头结点链表换成哈希表按用户名的哈希值分桶每桶一把锁解决全局锁竞争问题。给消息加上应用层序号和确认机制让 UDP 获得类似 TCP 的可靠传输能力同时保留低延迟特性。引入房间概念把广播范围从“所有人”缩小到“一个房间的人”这对流量控制更友好。把接收线程从单个扩展成多个配合SO_REUSEPORT让多个 socket 绑定同一端口进一步提升收包吞吐。如果用户量上万就得认真评估 epoll 加工作线程池的混合模型了把网络事件分发从线程拆分中彻底解耦。我个人在实际项目里体会最深的一件事是多线程本身不复杂复杂的是想清楚每个共享数据结构的访问边界。写代码前花十分钟画一张数据流图标出每个结构体被哪些线程读写比出了问题再调 gdb 高效得多。这个习惯我一直留着每次写并发程序都先做这一步。你把这个聊天室好好跑通、跑稳再琢磨扩展后面无论做游戏服务端、实时信令系统还是物联网网关这套底子都能复用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑