ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java Socket多线程服务器实战:从阻塞到并发,解决粘包与C10K

Java Socket多线程服务器实战:从阻塞到并发,解决粘包与C10K 如果你写过几年Java或者刚开始往服务端方向走一定绕不开这样一个问题两个进程之间到底是怎么通话的我第一次认真接触Socket是在实习时导师让我给内部工具写一个简单的消息推送服务端。我照着教程用ServerSocket写了一个能接收一条消息的Demo本地一测能通当时还挺高兴。结果第二天连第二个客户端直接把我给看懵了——新客户端连进来之后没有任何反应服务端也不报错就像卡住了一样。后来我才明白问题不是出在网络而是出在我对Socket模型的理解上accept()和readLine()都是阻塞的一个客户端不断开服务端永远等不到下一个accept()。这篇文章不打算堆OSI七层模型的概念而是从一次真实的卡死现场出发带着你用纯Java从Socket最基础的API开始一步一步写一个能同时处理大量客户端连接的多线程服务器。你会弄清楚Socket到底是什么、accept()为什么阻塞、线程池参数怎么定以及为什么有了多线程之后还会遇到拆包粘包这类新麻烦。适合刚学完Java基础、准备做服务端开发的同学也适合那些平时只用框架写业务、没搞懂底层网络模型的人。1. 先搞明白Socket到底是什么别急着写代码1.1 用一个打电话的类比理解Socket很多人一开始接触Socket都会被术语劝退连接、套接字、端口、握手、会话……其实你把Socket当成一个电话系统瞬间就通透了。IP地址就是对方的电话号码告诉你找哪台机器。端口就是分机号告诉你在这台机器上找哪个进程。一台服务器可以有好多服务它们都靠IP端口区分。TCP三次握手就是拨号到对方接起电话的过程。连接建立之后两端各自拿到一个Socket对象就像两个人各拿一部话筒你说话我能听到我说话你能听到数据双向流动。用这个类比去理解服务端代码会轻松很多。ServerSocket就是前台接线员坐席它的accept()方法相当于等待电话进来一旦有来电accept()返回一个Socket就相当于你接起电话开始通话。同样的道理可以解释一个常见报错BindException: Address already in use或者是你在一些框架日志里看到的only one usage of each socket address。你可以理解成这个分机号已经被别人占了两个进程不能同时绑定同一个IP端口。理解了这层再看Java的API就顺理成章了。Socket这个词本身也确实有插头、插座的含义——一端插在客户端一端插在服务端中间由TCP管道连通。1.2 网络分层里必须知道的两个点TCP和端口我不打算把计算机网络教材翻出来但有两点必须弄清楚否则后面你排查问题会一头雾水。第一TCP是什么。TCP是面向连接的、可靠的、基于字节流的传输协议。可靠意味着消息有确认、超时重传、排序、流量控制基于字节流则意味着TCP不保证你发一次就恰好被对端读一次它只保证字节顺序和完整性。这直接引出了后面要讲的拆包粘包问题——为什么你用readLine()读一条消息可能读到半条也可能一次读到好几条。第二端口号是16位无符号整数范围0到65535。一台主机上可以同时监听多个端口但要保证IP端口唯一。进程A绑定了0.0.0.0:8080进程B再想绑定同一个端口就会失败。排查端口冲突时记住后面这个套路先用netstat或lsof找到占用进程再决定是杀掉旧进程还是换端口。1.3 一个能跑通但很鸡肋的最小Demo先把最简单的代码写出来亲眼看到数据通再讨论改造。这个例子包含一个服务端和一个客户端服务端收到一行文本后回复一句然后退出。// 服务端 public class SingleServer { public static void main(String[] args) throws IOException { ServerSocket serverSocket new ServerSocket(8080); System.out.println(服务端已启动监听端口: 8080); Socket socket serverSocket.accept(); System.out.println(收到连接: socket.getRemoteSocketAddress()); BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out new PrintWriter(socket.getOutputStream(), true); String line in.readLine(); System.out.println(客户端说: line); out.println(你好客户端我收到了: line); socket.close(); serverSocket.close(); } }// 客户端 public class SimpleClient { public static void main(String[] args) throws IOException { Socket socket new Socket(127.0.0.1, 8080); BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out new PrintWriter(socket.getOutputStream(), true); out.println(hello server); String response in.readLine(); System.out.println(服务端回复: response); socket.close(); } }你先运行服务端再运行客户端会看到两边互相打印消息。这里有几个细节值得注意PrintWriter的第二个参数true表示自动flush所以println()后数据会立刻发出去否则可能要等到缓冲区满才发送。readLine()会一直阻塞直到读到\n或\r\n或者流关闭。这段代码的发收模式是一问一答客户端发一行服务端读一行、回一行。只要有一端没发换行符另一端就会一直等着。这个Demo最大的问题是服务端只处理一个连接处理完就退出。你连第二个客户端的时候操作系统层面TCP连接可能已经建立了但服务端进程已经没有再去调用accept()第二个客户端自然得不到任何回应。2. 踩坑现场为什么能跑通的Demo连不了第二个客户端2.1 现象复现三个终端看现场别光看我描述你自己花三分钟复现一次印象会非常深。终端1启动SingleServer。终端2启动SimpleClient正常收发。终端3再次启动SimpleClient程序就停在new Socket(127.0.0.1, 8080)这一步什么也不打印也不报错。更典型的一种场景是启动一个客户端后不退出保持连接占着然后再开第二个客户端。这第二个客户端的连接请求会一直在等待队列里躺着。用jstack看服务端线程你会发现主线程停在socketAccept方法上。为什么因为服务端主线程的代码顺序是先accept()拿到第一个连接然后进入readLine()等消息。只要第一个客户端还连着、还没发消息readLine()就不会返回代码永远不会执行到第二个accept()。2.2 accept()和readLine()为什么是卡住的这是Java网络编程最核心的一个理解点阻塞I/O。ServerSocket.accept()在没有客户端连接进来时线程会挂起直到内核通知有连接完成了三次握手才返回。BufferedReader.readLine()同理在内核缓冲区里没有读到一行数据时线程也会挂起。对操作系统来说这种挂起并不浪费CPU但浪费的是你只能处理这一个事情的能力。用一个更生活化的例子你是公司前台唯一的接线员。你正在听第一个客户电话里滔滔不绝地说话这时第二个客户打进来电话铃一直在响但你腾不出手去接。你不是不响应而是被当前通话阻塞了。这就是为什么单线程服务器无法同时服务多个客户端。不是Java的问题是阻塞串行这种组合注定了只能排队处理。2.3 内核的等待队列第二个连接其实连上了但没被接走很多人会误解第二个客户端卡住是连接没建立成功。实际上TCP三次握手大概率已经完成了连接进入了内核为监听Socket维护的accept queue等待被accept()取走。如果你的代码迟迟不调accept()队列会越积越多队列满了之后新的连接请求就可能被内核丢弃或者客户端看到连接超时。这里可以引出一个被很多人忽略的参数ServerSocket(int port, int backlog)。第二个参数backlog就是内核允许排队的连接数量上限。默认值因操作系统而异通常是50或128。如果你的业务是短连接且并发量很大适当调大backlog可以降低握手失败率但它并不能解决服务端来不及处理的根因。2.4 最朴素的修复思路加while循环为什么还是不行很多有经验一点的初学者会想那我用while(true)包住accept()每次收到连接就处理然后进入下一轮accept()不就行了while (true) { Socket socket serverSocket.accept(); BufferedReader in ...; PrintWriter out ...; String line in.readLine(); out.println(收到: line); }这种写法能解决短连接、一问一答的场景每个客户端连上来发一行消息收一条回复然后断开服务端处理完这个再accept()下一个。但一旦客户端是长连接比如聊天软件、游戏服务器、消息推送客户端连上后可能几秒甚至几分钟不发数据readLine()就会阻塞在第一个客户端上后面排队的客户端又全部没人管。结论是阻塞串行处理的方式只适合单次交互立即结束的极简场景。只要协议是长连接就必须让接受连接和处理连接解耦给每个连接分配独立的执行资源。这就是多线程服务器的动机。3. 多线程服务器的第一版实现线程池是标配不是进阶3.1 为什么要用线程池而不是每连接一个new Thread最直接的多线程玩法是accept()返回一个Socket后立刻new Thread(() - handleClient(socket)).start()。这段代码确实能让多个客户端同时连接但它有两个非常现实的问题。第一线程创建和销毁的代价高。每次都要向操作系统申请栈空间、注册进调度器用完又要回收。如果客户端连接是高频短连接开销很可观。第二线程数量不可控。每连接一线程意味着线程数随连接数线性增长。来1000个连接就有1000个线程来10000个连接就有10000个线程。线程太多时CPU时间片大量浪费在线程切换上甚至可能把内存耗尽。这也是我不建议在for循环里频繁new Thread的原因——如果你真的需要并发线程池几乎是更好的选择。线程池的本质是复用固定数量的线程让所有连接共享这些执行资源。连接多了任务在队列里排队线程资源不够策略来兜底。多线程服务器要做的不是连接越多线程越多而是在有限线程资源内尽量高效地服务所有连接。3.2 线程池参数怎么定别再用Executors默认了很多教程喜欢用Executors.newFixedThreadPool(10)做Demo完全够但我不建议你在服务器项目里直接照抄。原因是newFixedThreadPool的队列是无界的LinkedBlockingQueue如果任务积压太多队列会内存暴涨newCachedThreadPool则可能无限创建线程。学习阶段最好直接接触ThreadPoolExecutor把参数含义搞清楚。ThreadPoolExecutor pool new ThreadPoolExecutor( 8, // corePoolSize 核心线程数 32, // maximumPoolSize 最大线程数 60L, TimeUnit.SECONDS, // 非核心线程空闲存活时间 new ArrayBlockingQueue(128), // 有界任务队列 r - { Thread t new Thread(r); t.setName(client-worker); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() );几个参数怎么理解corePoolSize平时常驻线程数。网络服务器属于I/O密集线程适当多一些没坏处8到16是常见的起步值。maximumPoolSize线程数上限。当队列满了线程池才会创建额外的线程直到这个上限。workQueue排队的任务队列。有界队列更安全128或256是经验起步值具体要看你的内存和连接量。拒绝策略队列满且线程数到上限时的处理方式。AbortPolicy默认会抛异常CallerRunsPolicy会由调用者线程直接执行任务天然限流适合服务器不想直接拒绝请求的场景。核心思想是先在队列里排队排不下再扩线程线程到上限还有任务来就触发拒绝策略。这能防止系统在突发流量下被打垮。3.3 一个完整可运行的多线程EchoServer下面这个例子同时服务多个客户端每个客户端可以反复发消息服务端原样回复发quit时断开连接。public class ThreadPoolEchoServer { private static final int PORT 8080; public static void main(String[] args) throws IOException { ThreadPoolExecutor pool new ThreadPoolExecutor( 8, 32, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(128), r - { Thread t new Thread(r); t.setName(client-worker); return t; }, new ThreadPoolExecutor.CallerRunsPolicy() ); try (ServerSocket serverSocket new ServerSocket(PORT)) { System.out.println(服务器启动成功端口: PORT); while (true) { Socket socket serverSocket.accept(); pool.execute(() - handleClient(socket)); } } } private static void handleClient(Socket socket) { System.out.println(新连接: socket.getRemoteSocketAddress() , 处理线程: Thread.currentThread().getName()); try { BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter out new PrintWriter(socket.getOutputStream(), true); String line; while ((line in.readLine()) ! null) { System.out.println([ socket.getRemoteSocketAddress() ] - line); if (quit.equalsIgnoreCase(line)) { out.println(bye); break; } out.println(echo: line); } } catch (IOException e) { System.out.println(连接异常: e.getMessage()); } finally { closeQuietly(socket); } } private static void closeQuietly(Socket socket) { try { socket.close(); } catch (IOException ignored) { // 忽略关闭异常 } } }测试方法很简单# 终端1启动服务端 java ThreadPoolEchoServer # 终端2 telnet 127.0.0.1 8080 # 终端3 nc 127.0.0.1 8080两个终端同时连服务端都能一一回复。你还能在服务端日志里看到不同连接由不同线程名的线程处理这就是多线程服务器最直观的样子。3.4 处理客户端时最常见的两个失误第一忘了处理循环里的阻塞读。如果你只读一次客户端消息就返回连接过期后线程还一直被占着。上面代码用while ((line in.readLine()) ! null)只要客户端不断开线程就一直服务它客户端断开后readLine()返回null循环退出finally里关闭Socket线程归还给线程池。第二把socket.close()写在catch里而不是finally里。如果out.println或in.readLine抛异常连接就没被正常关闭会造成连接泄漏。每次都坚持在finally里close这是个好习惯。4. 多线程解决了并发又带出了三个新麻烦4.1 消息边界为什么生产环境不推荐裸readLinereadLine()看起来很好用但它本质是把换行符当成了消息边界。这在简单文本协议里可以工作放到生产环境则有三个问题。第一如果业务消息里本身就包含换行符拆包就错了。比如你要传输一段JSON格式化后带很多换行用readLine()读出来的只是一行而不是一条完整消息。第二TCP是字节流readLine()读取时如果一条消息还没发完整比如客户端发了hel网络就中断了服务端会一直卡在readLine()上等不到那最后的lo\n。第三当客户端发消息很快、发送缓冲合并时一次readLine()可能读出来的是两条消息拼在一起的粘包也可能读出来一条消息被拦腰截断的半包。所以业界设计网络协议时通常有三种方案定长消息约定每条消息固定N字节不够补位。适合字段固定、长度固定的场景。分隔符协议约定以某个特殊字符作为边界比如HTTP的\r\n\r\n头部分隔。长度前缀最通用开头4字节表示消息体长度再跟真正的消息体。无论做IM还是RPC都推荐这种方式。学习阶段用readLine()理解原理没问题但你要清楚它的局限性。真要写生产级服务器建议直接用Protobuf配合Netty或者至少自己封装一个长度字段的编解码器。4.2 连接管理服务器主动推送时Socket引用放哪EchoServer只做了收到消息再回复但如果服务器要主动推送消息给某个客户端比如通知另一个玩家上线就得保存所有客户端连接。常见做法是维护一个ConcurrentHashMapprivate static final MapString, Socket clients new ConcurrentHashMap(); public static void addClient(Socket socket) { clients.put(socket.getRemoteSocketAddress().toString(), socket); } public static void removeClient(Socket socket) { clients.remove(socket.getRemoteSocketAddress().toString()); }这段代码的注意点必须用ConcurrentHashMap不能用HashMap。因为多个工作线程都在往map里put/removeHashMap在并发写时可能造成死循环Java 8之前或数据错乱。连接关闭时记得remove否则这个Socket对象一直被map引用无法被GC回收就是典型的内存泄漏。如果要给所有客户端广播消息遍历map逐个写。但注意每个客户端的输出流不能被两个线程同时写否则消息会交错。简单做法是按连接粒度加锁或者用一个线程池/队列把写操作串行化。很多初学者一上来就想搞广播其实第一步是把连接存好、删对。否则上线通知写着写着就出现内存泄漏或消息错乱。4.3 资源回收close不是小事Socket和文件流一样用完必须关。而且关闭顺序有讲究直接调用socket.close()是最稳妥的它会关闭输入流和输出流并发送FIN给对端触发TCP四次挥手。如果你只关闭inputStream或outputStream在不同平台上行为有差异有的会连带关闭Socket有的不会很容易踩坑。我在实际项目里还会做两件事把Socket引用放进finally里统一close尽量不写多个手动关闭点。对读到的异常做区分。比如客户端正常断开时readLine()返回null如果是连接被重置会抛SocketException: Connection reset。这两类都不算严重的业务异常打日志要注意级别别把正常踢下线打成ERROR刷屏。4.4 端口绑定的经典报错与排查套路写服务器程序几乎每个人都会遇到BindException: Address already in use。它的本质是bind阶段发现IP:端口已被占用无论是Java、Node、Python还是Go底层都是同一个EADDRINUSE。排查套路是固定的在Linux上netstat -anp | grep 8080 ss -lnpt | grep 8080 lsof -i :8080在Windows上netstat -ano | findstr 8080 taskkill /F /PID 进程号很多时候是你上次启动的Java进程没被停掉。注意服务端重启时如果旧的连接还处于TIME_WAIT状态某些场景下绑定时会受影响。ServerSocket.setReuseAddress(true)可以在服务端重启后更快地重用端口但要在bind之前设置。这个选项主要影响TIME_WAIT状态不是万能的。顺便说一句日常开发里MySQL连接时遇到的ERROR 2002 (HY000): Cant connect to local MySQL server through socket /tmp/mysql.sock也属于Socket连接失败的典型场景不过它是Unix Domain Socket走的是文件系统路径而不是IP端口别和TCP Socket搞混。4.5 线程池别只用来干活命名、监控、拒绝策略在生产环境里线程池跑起来之后你会面临一个很现实的问题不知道系统里有多少线程在跑不知道谁创建了它们。所以从第一天起就给工作线程命名这个习惯会救你很多次。上面代码里t.setName(client-worker)就是这么回事。等出问题时你jstack一看线程名立刻知道是哪个业务线程占着资源。有条件的话可以把线程池的关键指标打监控活跃线程数、队列大小、完成任务数、拒绝次数。这些东西在流量高峰时比什么监控都直观。5. 验证这个服务器的真实能力压测与边界测试5.1 用telnet和nc先做冒烟测试代码写完先别急着上压测先用工具连一次确认基本功能没问题。# 启动服务端 java ThreadPoolEchoServer # 终端A telnet 127.0.0.1 8080 # 输入 hello 回车看到 echo: hello # 输入 quit 回车看到 bye连接断开 # 终端B nc 127.0.0.1 8080这一步能验证accept、readLine、println、close这一整条链路是通的。5.2 写一个多线程客户端做并发验证想验证服务器能同时处理多个客户端最简单的方式是写一个并发客户端一口气开几十个连接同时发消息。用CountDownLatch等所有线程结束最后看成功数量。public class MockClients { public static void main(String[] args) throws InterruptedException { int total 50; CountDownLatch latch new CountDownLatch(total); ExecutorService pool Executors.newFixedThreadPool(20); for (int i 0; i total; i) { int id i; pool.execute(() - { try (Socket socket new Socket(127.0.0.1, 8080); BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out new PrintWriter(socket.getOutputStream(), true)) { out.println(msg- id); String resp in.readLine(); System.out.println(客户端 id 收到: resp); } catch (IOException e) { System.err.println(客户端 id 失败: e.getMessage()); } finally { latch.countDown(); } }); } latch.await(10, TimeUnit.SECONDS); pool.shutdownNow(); System.out.println(压测结束); } }跑这个程序时注意观察服务端的线程池状态可以用jstack pid看工作线程数。正常情况下50个连接不会同时耗尽32个线程因为每个任务占一个线程直到连接关闭如果你改成每个客户端连接后长时间挂起线程数就会涨上去。5.3 每连接一线程的极限在哪C10K问题的入口多线程服务器的实现到这一步能处理并发连接这个目标已经达成。但它有明显的天花板当连接数到达上万级别时每连接占用一个线程的代价就绷不住了。你算算一个线程默认栈空间1MB一万个连接就算线程不干活也可能吃掉几个GB内存再加上上下文切换开销服务器会在连接量还很高时先把自己拖垮。这就是经典的C10K问题。怎么解决方向是事件驱动和多路复用也就是Java NIO里的Selector机制一个线程可以同时监听成千上万个Channel的事件只有真正有数据可读时才触发处理。理解了阻塞I/O的瓶颈你就明白为什么Netty会诞生为什么现在主流RPC框架全都基于Netty。5.4 下一步学习路线别急着扣Netty我的建议是先把本文这个线程池版本玩熟做一些有真实感的改造让底层概念长进肌肉里改造一让它支持客户端之间互相发消息需要维护客户端集合加广播。改造二加入心跳机制超过一段时间没收到消息就踢掉连接。改造三限制单IP最大连接数防止某个客户端把所有线程池资源吃满。改造四把消息格式从纯文本换成长度前缀字节数组亲身体会一次拆包粘包。这些项目任何一个都值得写几百行代码。等你把它们全踩一遍再去看NIO和Netty会觉得豁然开朗。我个人在实际项目里很少直接基于裸ServerSocket写生产服务器但每当我需要排查连接异常、调线程池参数、设计通信协议时当初手写这个多线程EchoServer打下的底子都会帮上大忙。网络编程最怕的就是框架把细节都隐藏了遇到诡异问题不知从何下手。从Socket到多线程服务器这条路走通了后面学什么都快。
返回列表