前两天有个同学在群里抛了一个结论:“Java线程池在2万并发时CPU直接打满,换成Go的goroutine之后,10万并发都轻轻松松,所以线程这个模型是不是该淘汰了?”我当时回了句:你先把两个测试的服务代码贴出来,八成有一个在忙等,有一个真的在sleep。后来他把代码发过来,果然Java那边用了同步阻塞IO,线程全部卡在等待上,内核不断做上下文切换;Go那边则走的是网络轮询和goroutine调度,两个模型根本不是同一个维度的东西。
这个场景其实很典型。Go协程和线程性能对比这个话题,几乎每个做后端的人都会遇到,尤其是从Java、C++转过来的同学,最先被安利的就是“goroutine成本低到可以随便开”。但问题在于,“低到可以随便开”不代表线程可以被降维打击,也不代表你的业务里只剩goroutine这一种选择。这篇文章我想用自己的实测数据和底层原理,把这两者的性能差异拆开讲透。你会看到它们分别强在哪、弱在哪,以及在真实业务里到底应该怎么取舍。适合正在做技术选型、准备高并发面试,或者单纯想把并发模型搞清楚的同学阅读。
1. 为什么我从一个“反常识结论”开始聊
1.1 线上JVM线程池被打穿,第一反应不是加线程
我之前维护过一个老项目,里面用Java线程池处理外部系统的回执消息,高峰期流量一上来,线程池队列就开始堆积,紧接着监控报警线程活跃数接近上限。当时团队里最本能的反应是把核心线程数往上调,从200调到500,再从500调到800。表面上看问题确实缓解了一小段时间,但代价是系统整体响应变慢,CPU的sys占用率明显升高,最后连垃圾回收都开始不规律。
这个案例后来成了我判断线程模型的一个反面教材。不是线程池本身垃圾,而是我们拿它硬扛了一个本质上是“海量短连接+IO等待”的场景。每个任务拿到外部连接后大部分时间都在等响应,等的时候线程被内核挂起,等响应回来后又要被重新唤醒。线程数量越大,内核在这上面的调度成本就越高,可真正在计算的CPU时间反而没多少。换句话说,线程的并行能力没问题,但它的资源占用和调度开销在当前场景里成了一种负担。
后来我把这部分逻辑迁移到Go的服务里,每个连接开一个goroutine,代码看着更简洁,压力也确实小了很多。当时直观的感觉是:同样的并发量,goroutine版本的内存占用和延迟都要好不少。但要问好在哪个环节,当时的我说不清楚,只知道“goroutine很轻”。
1.2 泛泛地吹“协程比线程更轻”很容易翻车
网上很多文章标题写得很猛,像“线程已经过时”“协程吊打线程”,但如果只停留在结论上,不解释底层调度机制,读者很容易产生两个误解。
第一个误解是把协程当成一种“更小的线程”,以为两者的差别只是数量级不同,比如一个goroutine占2KB,一个线程占8MB,那goroutine当然厉害。实际上线程是操作系统内核管理的执行体,goroutine是Go运行时自己管理的并发单元,创建、切换、销毁都由用户态调度器完成,只有最终执行时才被绑定到某个内核线程上。这两者走的不是同一条调度链路,光是拿栈大小去解释性能差异远远不够。
第二个误解是认为“用goroutine就一定比用线程快”。如果你把一个纯CPU密集的计算任务拆成一百万个goroutine,而机器只有8个核,最终同一时刻能干活的只有8个,大量的goroutine只是在等待被调度,性能不但不会更好,还可能因为调度器本身的压力变差。并发模型解决的核心问题是“如何高效表达和调度大量任务”,不是单纯地给代码提速。
1.3 这篇对比到底在比什么
为了避免又写一篇情绪化水文,我先约束一下讨论边界。这里说的线程,指的是操作系统原生线程,或者工程里依赖原生线程实现的传统线程池,比如Java的ThreadPoolExecutor、C++的std::thread这类体系;这里说的协程,特指Go语言的goroutine。JDK 21里也有虚拟线程,思路类似但实现细节不同,不在这次的对比范围内。
同时我会把性能拆成几个维度来聊:创建成本、内存占用、调度切换成本、可承载并发规模、CPU密集场景下的表现。每个维度背后的原理不同,结论也可能相反。只有把维度拆开,你才能真正理解为什么在IO密集型高并发场景下goroutine占优,也才能反过来理解线程在哪些场景依然不会被淘汰。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 线程与goroutine的底层机制到底差在哪
2.1 线程:由内核调度的执行体,生命周期并不便宜
先从一个基础问题说起:进程和线程有什么区别。教科书上的标准答案是,进程是资源分配的最小单位,线程是CPU调度的最小单位。这句话放到现代操作系统里依然成立。进程拥有独立的地址空间、文件描述符表等资源,线程则共享进程的地址空间和大多数资源,但每个线程有自己的程序计数器、寄存器上下文和栈。
线程的创建和切换,核心动作都在内核态完成。当你调用pthread_create或者Java里new Thread时,底层会通过clone等系统调用创建一份内核线程结构,同时分配栈空间。这个操作不是免费的,需要陷入内核,可能要经过一系列权限检查和资源分配。调度时,内核需要把当前线程的寄存器状态保存起来,再从运行队列里选下一个线程恢复执行,这个过程涉及用户态和内核态的切换,专业一点叫上下文切换。
还有个很容易被忽略的细节是线程栈大小。Linux上glibc的线程默认栈一般是8MB,虽然这个8MB绝大部分是虚拟内存,不一定会立刻对应到物理内存,但大量线程创建之后,光是虚拟地址空间和内核为每个线程维护的管理结构就会造成很大压力。实际生产中线程数量上了千就要开始谨慎评估,上万往往就要调系统参数,十万基本是噩梦。
所以线程模型的核心问题是:它不是不好用,而是贵。每一条线程都是一个完整的内核调度实体,需要内核“认真对待”。
2.2 goroutine:运行在用户态的并发单元,为什么能谈“轻”
goroutine是Go运行时创建的用户态执行单元。当你写下go func() {},Go运行时会在当前进程内申请一小块内存,初始化协程栈和相关状态,然后把它扔进调度器
