☰
多线程Socket通信实战:从单用户到多客户端并发
2026/10/19 5:16:59 网站建设 项目流程

1. 从单用户到多用户:为什么Socket通信必须引入多线程

很多刚接触网络编程的朋友,第一个练手的项目往往就是写一个简单的Socket通信Demo——服务端开一个端口,客户端连上来,发一句话,服务端回一句话,然后双方关掉连接,程序结束。这个流程跑通之后,你会觉得网络编程好像也没那么难。但只要你稍微往前走一步,比如想让两个客户端同时连上来聊天,问题立刻就暴露了:第二个客户端连不上,或者连上了但服务端完全没反应。

这个现象背后的原因其实很朴素。传统的单线程服务端代码大概长这样:ServerSocket绑定端口后调用accept()方法,这个方法会一直阻塞,直到有客户端连接进来。连接建立后,程序继续往下走,读取客户端发来的数据、处理、回复,处理完再回到循环开头,再次调用accept()等待下一个连接。问题就在于,如果第一个客户端连上来之后一直不发送数据,或者发送数据很慢,服务端的线程就一直卡在读取操作上,根本没有机会回到accept()去接收新的连接。换句话说,服务端被第一个客户端“霸占”了。

这就好比一家餐厅只有一个服务员,他接待了第一桌客人之后,就一直站在那桌旁边等人家点菜,其他客人进门他完全看不见。要让餐厅正常运转,要么多雇几个服务员,要么让服务员在等菜的时候先去招呼别的桌。多线程Socket通信解决的就是这个问题——为每一个连接分配一个独立的线程去处理,主线程专心负责接收新连接,两者互不干扰。

这个项目适合谁呢?如果你已经写过最简单的Socket通信Demo,理解了Socket和ServerSocket的基本用法,但对“怎么让多个客户端同时在线”这个问题还没有清晰的思路,那这篇内容就是为你准备的。我会从整体设计思路讲起,然后拆解线程模型的选择、代码的逐行实现、资源管理、常见Bug的排查,最后聊一聊从多线程继续往下走的扩展方向。整篇内容会给出可以直接运行的代码,也会解释每一处设计决策背后的原因,让你不仅能把代码跑起来,还能理解为什么这么写。

2. 整体架构设计:线程模型怎么选

2.1 主从线程模型的职责划分

多线程Socket服务端最经典的设计叫“主从线程模型”,也有人叫它“一连接一线程”模型。这个模型的核心思想非常直接:主线程只做一件事——调用accept()等待客户端连接;一旦有客户端连上来,就立刻创建一个新的工作线程,把这个客户端对应的Socket对象交给工作线程去处理,主线程马上回到accept()继续等待下一个连接。

这个分工的好处在于职责清晰。主线程的任务极其单一,就是“接客”,它不会被任何客户端的数据读写操作阻塞。工作线程的任务也很明确,就是“服务好自己负责的那个客户端”,读数据、处理业务逻辑、写回响应,全部在这个线程内部完成。两个线程各干各的,互不干扰。

用生活场景来类比:主线程就像公司前台,她的工作就是接待来访者,每来一个人就安排一位同事去对接,安排完立刻回到前台继续等人。工作线程就是那位被安排的同事,他带着来访者去会议室,慢慢聊,聊多久都行,不影响前台接待下一个人。这种模式在客户端数量不多、每个连接都需要持续交互的场景下非常合适,比如小型聊天室、局域网内的文件传输工具、简单的远程控制程序等。

2.2 为什么不用线程池而用独立线程

看到“为每个连接创建一个线程”,有经验的朋友可能会立刻想到一个问题:为什么不直接用线程池?线程池确实能控制线程数量、复用线程资源,听起来更优雅。但在Socket通信这个特定场景下,独立线程有它不可替代的优势。

线程池的核心假设是“任务短小且执行时间可控”。你提交一个任务,线程池分配一个线程去执行,执行完了线程归还池子,下一个任务复用这个线程。但Socket连接的生命周期往往是不确定的——一个客户端可能连上来聊两句就走,也可能挂在那里几个小时不动。如果把这个连接的处理逻辑作为一个任务提交给线程池,那么这个线程就会被这个连接一直占用,直到连接关闭才能归还。如果线程池的最大线程数设得比较小,几个长连接就能把池子占满,后面的连接全部排队等待,体验反而更差。

当然,这不是说线程池不能用。如果你的场景是“每个连接处理一次请求就关闭”的短连接模式,线程池是非常合适的选择。但如果是长连接、持续交互的场景,独立线程更简单直接,不需要去调线程池参数,也不会出现任务排队导致的响应延迟。我个人的经验是:先跑通独立线程的版本,理解清楚整个流程之后,再根据实际压力测试的结果决定要不要换成线程池或者NIO。

2.3 需要提前想清楚的几个设计决策

在动手写代码之前,有几个设计决策需要提前想清楚,它们会直接影响代码的结构和后续的扩展性。

第一个决策是通信协议。最简单的做法是用纯文本行协议,客户端发一行字符串,服务端读一行、处理、回一行。这种方式的优点是调试方便,用Telnet或者Netcat就能直接测试,不需要额外写客户端。缺点是无法传输二进制数据,如果消息里包含换行符还需要额外转义。对于学习和中小型项目来说,文本行协议完全够用。

第二个决策是线程的退出机制。工作线程什么时候结束?最自然的答案是“客户端断开连接时结束”。但客户端断开的方式有很多种:正常关闭Socket、直接杀进程、网线拔掉。前两种情况下服务端的读取操作会返回-1或者抛出异常,线程可以正常退出。但如果是网线拔掉这种“半开连接”,服务端可能很久都感知不到,线程就一直挂着。这个问题在局域网环境下不太明显,但如果部署在公网服务器上就需要考虑心跳机制。

第三个决策是共享资源的处理。如果多个工作线程需要访问同一个数据结构,比如一个在线用户列表,就必须考虑线程安全问题。最简单的做法是用ConcurrentHashMap或者给关键代码块加synchronized。如果每个线程只操作自己连接的数据,那就不需要额外的同步措施,这也是“一连接一线程”模型的一个隐性优势。

3. 核心代码逐行拆解:服务端与客户端实现

3.1 服务端主线程:accept循环的正确写法

先来看服务端主线程的代码骨架。这段代码的核心就是一个while(true)循环,里面调用accept(),拿到连接后启动工作线程。

public class MultiThreadServer { private static final int PORT = 8888; public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(PORT); System.out.println("服务端已启动,监听端口:" + PORT); while (true) { Socket clientSocket = serverSocket.accept(); System.out.println("新客户端接入:" + clientSocket.getRemoteSocketAddress()); new Thread(new ClientHandler(clientSocket)).start(); } } }

这段代码看起来简单,但有几个细节值得展开说。

ServerSocket的构造参数是监听端口,这里用8888只是一个习惯,你可以换成任何1024以上的未被占用端口。1024以下的端口在Linux和Mac上需要管理员权限才能绑定,开发阶段没必要给自己找麻烦。

accept()方法返回的是一个Socket对象,这个对象代表服务端和某个特定客户端之间的一条连接。注意,每次accept()返回的都是不同的Socket实例,它们各自维护自己的输入输出流。这一点很关键,因为工作线程需要拿到这个Socket对象才能和对应的客户端通信。

new Thread(new ClientHandler(clientSocket)).start()这一行是主从模型的核心。ClientHandler是一个实现了Runnable接口的类,它接收clientSocket作为构造参数,在run()方法里完成所有的读写逻辑。start()方法启动线程后立即返回,主线程继续下一轮循环,不会被工作线程的执行所阻塞。

注意:accept()方法本身会抛出IOException,在真实项目中需要妥善处理。如果ServerSocket被关闭,accept()会抛出SocketException,这时候应该跳出循环而不是继续重试。上面的示例为了简洁直接往外抛了,实际项目中建议用try-catch包裹,根据异常类型决定是继续还是退出。

3.2 工作线程:读取、处理、响应的完整流程

工作线程的代码是整个服务端的业务核心。它需要完成三件事:读取客户端发来的数据、根据数据内容进行处理、把处理结果写回客户端。

public class ClientHandler implements Runnable { private final Socket socket; public ClientHandler(Socket socket) { this.socket = socket; } @Override public void run() { try (BufferedReader reader = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter writer = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true)) { String line; while ((line = reader.readLine()) != null) { System.out.println("收到来自 " + socket.getRemoteSocketAddress() + " 的消息:" + line); String response = processMessage(line); writer.println(response); } } catch (IOException e) { System.out.println("客户端 " + socket.getRemoteSocketAddress() + " 连接异常:" + e.getMessage()); } finally { try { socket.close(); } catch (IOException ignored) { } System.out.println("客户端 " + socket.getRemoteSocketAddress() + " 已断开"); } } private String processMessage(String message) { if ("time".equalsIgnoreCase(message)) { return "服务器时间:" + java.time.LocalDateTime.now(); } else if ("bye".equalsIgnoreCase(message)) { return "再见"; } else { return "服务端已收到:" + message; } } }

这段代码里有几个关键点需要解释。

第一,流的包装。socket.getInputStream()返回的是一个原始的字节流,直接读字节很不方便。用InputStreamReader把它转成字符流,再套一层BufferedReader,就可以用readLine()方法按行读取了。readLine()会一直阻塞,直到读到一行完整的数据(以换行符结束)或者流被关闭(返回null)。输出流同理,用PrintWriter包装之后可以直接用println()发送一行文本,构造参数里的true表示自动刷新,每次调用println()都会把数据真正推出去,不需要手动调flush()。

第二,字符编码。InputStreamReader和OutputStreamWriter都显式指定了UTF-8编码。如果不指定,会使用平台默认编码,在中文环境下可能出现乱码。显式指定编码是一个好习惯,尤其是在跨平台部署的时候。

第三,while ((line = reader.readLine()) != null)这个循环是工作线程的生命线。只要客户端没有断开,readLine()就会阻塞等待数据;一旦客户端关闭了输出流或者整个Socket,readLine()会返回null,循环结束,线程自然退出。如果客户端异常断开,readLine()会抛出IOException,被catch块捕获,同样走到finally里关闭Socket。

第四,finally块里的socket.close()非常重要。Socket是操作系统资源,不关闭的话会一直占用文件描述符。虽然进程退出时操作系统会回收,但在长时间运行的服务端程序里,如果不主动关闭,迟早会耗尽文件描述符,导致新的连接无法建立。用try-with-resources管理输入输出流可以自动关闭流,但Socket本身还是建议显式关闭,确保万无一失。

3.3 客户端实现:连接、发送、接收的完整交互

客户端比服务端简单得多,它只需要连接指定的服务端地址和端口,然后在一个循环里读取用户输入、发送给服务端、接收服务端的响应。

public class ChatClient { public static void main(String[] args) throws IOException { Socket socket = new Socket("127.0.0.1", 8888); System.out.println("已连接到服务端"); BufferedReader serverReader = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter serverWriter = new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true); BufferedReader consoleReader = new BufferedReader( new InputStreamReader(System.in, StandardCharsets.UTF_8)); String userInput; while ((userInput = consoleReader.readLine()) != null) { serverWriter.println(userInput); String response = serverReader.readLine(); if (response == null) { System.out.println("服务端已关闭连接"); break; } System.out.println("服务端回复:" + response); if ("bye".equalsIgnoreCase(userInput)) { break; } } socket.close(); } }

客户端的逻辑是“一问一答”式的:从控制台读一行用户输入,发给服务端,然后阻塞等待服务端的回复,收到回复后打印出来,再继续下一轮。这种模式在单线程客户端里是合理的,因为用户本来就是发一条等一条。但如果你想让客户端也能随时接收服务端主动推送的消息,就需要把接收逻辑放到单独的线程里,这个后面会展开说。

new Socket("127.0.0.1", 8888)这一行会发起TCP三次握手,如果服务端没有在监听或者网络不通,会抛出ConnectException。在实际项目中,建议给连接操作设置超时时间,避免程序卡死。Socket的connect()方法可以指定超时毫秒数,不过用构造函数的话默认是没有超时的,需要改成先创建无参Socket再调connect()。

3.4 多客户端并发测试:验证线程模型是否生效

代码写完之后,怎么验证多线程确实生效了?最简单的办法是开三个终端窗口。第一个窗口运行服务端,第二和第三个窗口分别运行客户端。然后在两个客户端里交替发送消息,观察服务端的输出。

如果一切正常,你会看到服务端打印出类似这样的日志:

服务端已启动,监听端口:8888 新客户端接入:/127.0.0.1:54321 新客户端接入:/127.0.0.1:54322 收到来自 /127.0.0.1:54321 的消息:hello 收到来自 /127.0.0.1:54322 的消息:world

两个客户端的消息交错出现,说明两个工作线程在并发执行。你可以做一个更极端的测试:在客户端A里输入一条消息后不按回车,让客户端A的工作线程阻塞在readLine()上,然后在客户端B里正常发送消息。如果服务端能正常响应客户端B,就证明主线程没有被客户端A阻塞,多线程模型确实在工作。

还有一个测试技巧是在processMessage方法里加一个Thread.sleep(5000),模拟耗时的业务处理。然后快速用两个客户端各发一条消息。如果两个客户端都在大约5秒后收到回复,说明两个请求是并行处理的;如果第二个客户端等了10秒才收到回复,说明处理是串行的,多线程没有生效。这个测试能帮你快速定位线程模型是否正确。

4. 资源管理与线程安全:那些容易踩的坑

4.1 Socket与流的关闭顺序

资源关闭看起来是个小问题,但在Socket编程里,关闭顺序不对可能导致数据丢失或者异常。正确的关闭顺序是:先关闭输出流(或者调用shutdownOutput()),再关闭输入流,最后关闭Socket。但实际写代码的时候,用try-with-resources同时管理输入流和输出流,关闭顺序是由编译器决定的,通常是先创建的后关闭。

这里有一个容易忽略的坑:PrintWriter的close()方法会先调用flush(),把缓冲区里的数据推出去,然后再关闭底层流。如果你在发送完最后一条消息后直接关闭Socket而没有关闭PrintWriter,缓冲区里的数据可能还没发出去就被丢弃了。所以要么手动调用flush(),要么确保PrintWriter被正确关闭。

另一个坑是关于shutdownOutput()的。有时候你发送完数据后想告诉对方“我发完了”,但又不想关闭整个Socket(因为还想接收对方的响应)。这时候可以调用socket.shutdownOutput(),它会发送一个TCP FIN包,对方读到流末尾就知道你发完了,但连接还没有完全关闭,你仍然可以读取对方发来的数据。这个技巧在文件传输场景里很有用。

4.2 共享资源的线程安全处理

在“一连接一线程”模型里,如果每个工作线程只操作自己连接的数据,那基本不需要考虑线程安全。但一旦引入共享状态,比如一个记录所有在线用户的列表,问题就来了。

假设你想实现一个广播功能:任何一个客户端发送消息,服务端把这条消息转发给所有在线的客户端。这就需要维护一个所有客户端输出流的集合。多个工作线程可能同时往这个集合里添加或移除元素,也可能同时遍历这个集合发送消息。如果用的是普通的ArrayList,并发修改会抛出ConcurrentModificationException,或者导致数据不一致。

解决方案有两种。第一种是用ConcurrentHashMap或者CopyOnWriteArrayList这类线程安全的集合类。CopyOnWriteArrayList特别适合“读多写少”的场景,每次添加或删除元素时会复制整个底层数组,虽然写操作开销大,但读操作完全不需要加锁,遍历的时候也不会抛异常。第二种是给关键代码块加synchronized,但要注意锁的粒度,锁太粗会影响并发性能,锁太细又容易出死锁。

我个人的经验是:能用线程安全的集合类就用,实在不行再考虑手动加锁。ConcurrentHashMap的newKeySet()方法可以很方便地创建一个线程安全的Set,用来实现在线用户列表非常合适。

4.3 线程泄漏与连接数控制

“一连接一线程”模型最大的风险是线程数量失控。如果有一万个客户端同时连接,服务端就会创建一万个线程。每个线程默认占用1MB左右的栈空间(可以通过-Xss参数调整),一万个线程就是大约10GB内存,普通服务器根本扛不住。而且线程切换本身也有开销,线程太多反而会降低整体吞吐量。

控制连接数有几种思路。第一种是在accept()之后检查当前活跃线程数,如果超过阈值就拒绝连接,给客户端发送一个“服务器繁忙”的提示然后关闭。第二种是使用线程池,通过设置最大线程数来限制并发量,但前面说过,长连接场景下线程池的效果需要仔细调参。第三种是改用NIO(New I/O),用少量的线程管理大量的连接,这是高并发场景下的标准方案,但代码复杂度会显著上升。

对于学习项目和小型内部工具来说,不需要过度设计。但心里要清楚这个模型的上限在哪里,当连接数达到几百上千的时候,就应该考虑升级方案了。一个实用的建议是:在服务端加一个连接计数器,每次accept()时递增,工作线程结束时递减,定期打印当前连接数,这样你能直观地看到服务端的负载情况。

5. 常见问题排查与实战避坑指南

5.1 连接建立失败:端口占用与防火墙

客户端报Connection refused是最常见的连接问题。原因通常有三个:服务端没启动、端口号写错了、防火墙拦截了。

排查的第一步是确认服务端确实在监听。在Linux或Mac上可以用netstat -an | grep 8888或者lsof -i :8888查看端口状态。如果看到LISTEN状态,说明服务端正常。在Windows上可以用netstat -ano | findstr 8888。

如果服务端在本地监听正常,但远程客户端连不上,大概率是防火墙的问题。云服务器通常有安全组规则,需要手动放行对应的端口。本地开发时,Windows防火墙也可能拦截Java进程的入站连接,第一次运行时会弹窗询问,如果点了“取消”就会被静默拦截。遇到这种情况,去防火墙设置里找到Java相关的入站规则,改成允许即可。

还有一个隐蔽的坑是绑定地址。new ServerSocket(8888)默认绑定在所有网络接口上(0.0.0.0),本地和远程都能连。但如果写成new ServerSocket(8888, 50, InetAddress.getByName("127.0.0.1")),那就只监听本地回环地址,远程客户端是连不上的。检查代码里有没有类似的绑定限制。

5.2 消息收发异常:粘包、半包与编码问题

TCP是面向字节流的协议,它不保证发送方的一次write对应接收方的一次read。发送方连续发了三条消息,接收方可能一次全收到,也可能分三次收到,甚至一条消息被拆成两半收到。这就是所谓的“粘包”和“半包”问题。

用BufferedReader.readLine()按行读取可以在很大程度上规避这个问题,因为换行符天然就是消息边界。但前提是发送方每次发消息都以换行符结尾,而且消息内容本身不能包含换行符。如果消息内容里可能有换行,就需要更复杂的协议,比如先发一个固定长度的消息头声明消息体长度,接收方先读消息头再按长度读消息体。

编码问题也很常见。如果服务端用UTF-8编码发送中文,客户端用GBK解码,就会看到乱码。解决办法是在所有涉及字符流转换的地方都显式指定UTF-8。另外,PrintWriter的println()方法使用的是平台默认的行分隔符,在Windows上是\r\n,在Linux上是\n。如果服务端和客户端运行在不同平台,readLine()通常能正确处理这两种分隔符,但如果你自己手动解析字节流,就需要注意这个差异。

5.3 线程阻塞不退出:readLine的陷阱

工作线程卡在readLine()上不退出,是另一个高频问题。正常情况下,客户端关闭连接后,readLine()会返回null,循环结束,线程退出。但如果客户端没有正常关闭,比如进程被强制杀死、网线被拔掉,服务端的readLine()可能永远阻塞下去。

这个问题的根源在于TCP连接的“半开”状态。当一方异常断开时,另一方可能收不到FIN包,因此不知道连接已经失效。操作系统层面有一个TCP Keep-Alive机制,但默认的探测间隔是两小时,对于服务端来说太长了。

解决方案是在应用层实现心跳机制。服务端定期向客户端发送一个心跳包(比如一个空行或者特定的字符串),如果连续几次收不到客户端的响应,就主动关闭连接。或者反过来,要求客户端定期发送心跳,服务端设置一个读取超时时间(socket.setSoTimeout(30000)),如果30秒内没有收到任何数据,就认为连接已失效,抛出SocketTimeoutException,在catch块里关闭连接、结束线程。

setSoTimeout()这个方法值得特别说一下。它设置的是阻塞读取操作的超时时间,单位是毫秒。设置为0表示无限等待(默认值),设置为正数表示等待指定时间后如果还没有数据就抛异常。这个机制配合心跳包使用,可以有效清理掉那些“僵尸连接”,避免线程和文件描述符被白白占用。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
客户端报Connection refused服务端未启动或端口错误检查服务端日志和端口监听状态启动服务端,核对端口号
第二个客户端连不上服务端未使用多线程查看accept后是否创建新线程改为每个连接一个线程
中文显示乱码编码不一致检查两端字符流编码设置统一使用UTF-8
消息丢失或截断粘包/半包抓包或打印原始字节使用行协议或长度前缀协议
线程不退出客户端异常断开jstack查看线程状态设置SoTimeout+心跳机制
服务端内存持续增长Socket未关闭监控文件描述符数量finally块中确保close
并发修改异常共享集合未同步查看异常堆栈使用ConcurrentHashMap

6. 从多线程继续往前走:可扩展的方向

6.1 引入线程池控制并发规模

当你理解了独立线程模型之后,可以尝试把它改造成线程池版本。核心改动是把new Thread(new ClientHandler(socket)).start()换成executorService.submit(new ClientHandler(socket))。线程池的配置需要根据实际场景调整:如果连接数不多但每个连接交互频繁,可以用Executors.newCachedThreadPool(),它会按需创建线程并复用空闲线程;如果连接数稳定且需要限制上限,可以用Executors.newFixedThreadPool(n),但要注意队列积压的问题。

改造之后需要特别关注的是线程池的关闭。服务端退出时,应该调用executorService.shutdown(),它会等待已提交的任务执行完毕再关闭。如果希望立即关闭,可以用shutdownNow(),它会尝试中断正在执行的任务。但Socket的阻塞读取操作不响应中断,所以shutdownNow()可能无法立即终止工作线程,需要配合关闭所有Socket来实现。

6.2 用NIO支撑更高的并发量

多线程模型的天花板在于线程数量。当连接数达到几千甚至上万时,无论怎么优化线程池都力不从心。这时候就需要转向NIO(New I/O)模型。NIO的核心是Selector,它可以用一个线程同时监听多个Channel上的事件(连接就绪、读就绪、写就绪),哪个Channel有数据就处理哪个,不需要为每个连接分配一个线程。

NIO的代码复杂度比多线程高不少,需要理解Channel、Buffer、Selector三个核心组件,还要处理“半包”问题(因为NIO是非阻塞的,一次读到的数据可能不完整)。但它的扩展性是多线程模型无法比拟的,一个Selector线程可以轻松管理上万个连接。如果你后续要做高并发的服务端程序,NIO是必须跨过的一道坎。

6.3 加入心跳与断线重连机制

心跳机制前面提过,这里再展开说一下实现细节。服务端可以在每个工作线程里启动一个定时任务,每隔30秒向客户端发送一个心跳包。客户端收到心跳包后回复一个确认。如果服务端连续三次没有收到确认,就判定连接已断开,关闭Socket并结束线程。

客户端这边也需要断线重连的逻辑。当客户端检测到连接断开(读取返回null或者抛出异常)时,不要直接退出程序,而是等待几秒后尝试重新连接服务端。重连成功后再恢复正常的收发流程。这个机制在移动网络环境下特别重要,因为手机信号切换、WiFi断开等情况会导致连接频繁中断。

实现心跳和重连的时候要注意一点:重连不能无限快速重试,否则会给服务端造成压力。通常采用指数退避策略,第一次等1秒,第二次等2秒,第三次等4秒,以此类推,设置一个上限比如30秒。这样既能保证尽快恢复连接,又不会在服务端故障时雪上加霜。

6.4 协议设计:从文本行到结构化消息

文本行协议适合学习和简单场景,但真实项目里往往需要传输更复杂的结构化数据。比如一个聊天消息可能包含发送者、接收者、消息类型、时间戳、消息内容等多个字段。用文本行协议的话,需要自己定义分隔符和转义规则,容易出错。

更优雅的做法是使用JSON或者Protocol Buffers这类序列化格式。JSON的可读性好,调试方便,几乎每种语言都有成熟的解析库。Protocol Buffers的传输效率更高,但需要预先定义schema并生成代码。选择哪种取决于你的具体需求:如果追求开发效率和可调试性,JSON是首选;如果追求极致的传输性能和跨语言兼容性,Protocol Buffers更合适。

不管用哪种格式,都需要在消息前面加上长度前缀,这样接收方才能知道一条完整的消息有多长。常见的做法是消息头用4个字节表示消息体的长度,接收方先读4个字节,解析出长度N,然后再读N个字节,这样就能精确地拿到一条完整消息,彻底解决粘包和半包问题。

我在实际项目里踩过最深的坑,是早期用文本行协议传输文件内容时,文件里的换行符导致readLine()提前截断,接收到的文件总是缺几行。后来改成先发文件长度再发文件内容才解决。这个教训让我明白:文本行协议只适合传输“人类可读且不含换行符”的短消息,一旦涉及二进制数据或者长文本,必须换成带长度前缀的协议。另外,工作线程里所有涉及Socket的操作都要放在try-catch里,因为客户端随时可能断开,任何一个未捕获的异常都会导致线程静默死亡,而服务端主线程完全感知不到。我习惯在catch块里打印客户端的地址和异常信息,这样排查问题时能快速定位是哪个连接出了状况。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询