写并发编程这个系列,起因是太多开发者在实际项目里碰到同一类问题:代码单线程跑得好好的,一上多线程就各种诡异现象——数据对不上、程序卡死、CPU飙高但任务不推进。排查半天,根子都在并发基础不牢。这个系列第一篇,我打算把并发编程里最核心的认知框架讲透,包括进程、线程、协程的区别,线程生命周期,竞态条件怎么产生,以及同步机制里锁、原子操作、信号量各自的适用场景。内容偏向通用原理,但会配上可运行的代码示例,不同语言背景的读者都能对照着理解。
这篇适合两类人:一类是刚接触并发、被各种术语绕晕的新手,另一类是写过一些多线程代码、但遇到线上问题只能靠猜的开发者。看完你至少能建立一条清晰的排查链路:看到现象,能想到对应的并发概念,再定位到具体的代码问题。
1. 并发编程到底在解决什么问题
1.1 你以为的并发和实际的并发
很多人对并发有个误解,觉得并发就是"同时做多件事"。这个说法在日常生活中成立,但在计算机世界里不够精确。单核 CPU 上,所谓的并发其实是通过时间片轮转实现的——一个线程跑几十毫秒,切到另一个线程跑几十毫秒,来回切换的速度快到人类感知不到,看起来就像"同时"在跑。真正的同时执行,需要多核 CPU,每个核上并行跑一个线程,这才叫并行。
并发和并行之间的区别,不是咬文嚼字,而是直接影响你的程序设计思路。如果你的目标是并发,那重点在于怎么管理任务切换、怎么处理共享资源的访问;如果你的目标是并行,那重点在于怎么把任务拆分成可独立执行的子任务,让多个核都忙起来。很多性能问题就出在这里——代码写成了并发结构,但实际跑在一个核上,该并行的地方没并行,自然快不起来。
我见过一个真实的例子,某开发者用多线程处理一批图片,以为开了八个线程就能八倍速,结果跑出来的耗时反而比单线程还慢。原因很简单:任务本身是 CPU 密集型的,线程切换的开销加上锁竞争的开销,把多核的优势全吃掉了。这就是没搞清楚并发和并行适用场景的典型后果。
1.2 为什么单线程不够用
单线程模型最大的问题不是慢,而是阻塞。一个线程在做 I/O 操作(读文件、查数据库、调远程接口)的时候,其实是在等外部设备返回数据。这个等待时间里,CPU 是空闲的,但单线程程序也没法去做别的事,只能干等。程序的实际吞吐量,就被这些等待时间卡死了。
并发编程解决的核心问题,就是提高资源利用率——把等待 I/O 的时间拿去处理其他任务,让 CPU 尽量不闲着。这跟你烧水的时候顺便去洗菜是一个道理,顺序执行是先烧完水再洗菜,并发执行是水烧着的同时把菜洗了。生活中习以为常的统筹方法,在程序里就是并发。
但这里有个容易被忽略的点:并发不是免费的午餐。线程的创建和销毁要消耗资源,线程之间的切换要消耗 CPU 时间,多个线程访问同一份数据还要引入同步开销。如果你的任务本身非常轻量,几毫秒就执行完了,强行上并发反而得不偿失。判断标准也很简单——任务的耗时大头在等待 I/O 还是在计算?如果是前者,并发收益明显;如果是后者,得先确认有没有多核可以利用,否则并发只是给自己找麻烦。
2. 进程、线程与协程:并发的基本单元
2.1 三者的区别与联系
进程是操作系统分配资源的基本单位,每个进程有独立的地址空间、独立的文件描述符表。这意味着一个进程崩溃了,一般不会拖垮其他进程,但进程之间的通信成本较高,通常需要借助管道、消息队列、共享内存等机制。
线程是进程内部的执行单元,同一个进程里的线程共享地址空间和大部分资源。好处是线程之间通信简单,直接读写共享变量就行,坏处是隔离性差——一个线程越界写坏了内存,整个进程都跟着崩。线程的创建和切换成本比进程低,但也低不到哪去,每次切换都要保存和恢复一堆上下文。
协程是用户态调度的执行单元,它的切换不经过操作系统内核,而是由程序自己控制。切换成本非常低,低到你可以随意创建十万个协程而面不改色。但协程也有代价:它没法利用多核,除非配合多线程使用——每个线程上跑一批协程,协程之间协作调度,才能做到既轻量又能并行。
用一个类比来记这三者的关系:进程像一家公司,有自己的办公室和资产;线程像公司里的员工,共享办公室和资产,但各有各的工作内容;协程像员工手里的任务清单,员工可以自己决定先做哪条、暂停哪条,不用每次换任务都重新找领导审批。
2.2 如何选择合适的并发模型
选型没有绝对的对错,关键看场景。我自己的经验是分三步判断:
第一步,看任务边界。需要强隔离、怕互相影响的,选多进程;需要频繁共享数据、通信密集的,选多线程;需要超大规模并发连接、且主要是 I/O 密集型操作的,选协程。
第二步,看开发语言和生态。C/C++ 里线程是标配,配合锁和条件变量用得很顺手;Java 的线程模型成熟,连接池、线程池的工具链齐全;Go 语言干脆把 goroutine 作为一等公民,写并发代码的体验非常顺滑。语言本身的设计倾向,往往决定了哪种模型用起来最省心。
第三步,看维护成本。并发模型越底层,灵活度越高,但出错概率也越大。多进程通信繁琐但崩溃隔离好,多线程共享内存直接但容易出数据竞争,协程轻量但调度逻辑得自己心里有数。没有团队能长期维护一套自己都说不清规则的并发模型,所以选型时务必把团队的技术能力考虑进去。
实际项目里,这三种模型也不是互斥的。常见的架构是:多进程做服务隔离,每个进程内部用多线程处理请求,网络层用协程或异步 I/O 支撑高并发连接。我从不在项目一开始就绑定某一种模型,而是先画清楚数据流向和资源边界,再决定哪里用哪种手段。这比纠结"哪个模型最好"要实际得多。
3. 线程的创建与生命周期管理
3.1 不同语言里线程怎么创建
先看最基础的线程创建方式。不同语言语法不同,但背后的概念是一致的:定义一个任务函数,交给线程去执行。
以 Java 为例,最朴素的创建方式是继承 Thread 类或者实现 Runnable 接口:
Thread t = new Thread(() -> { System.out.println("线程执行中:" + Thread.currentThread().getName()); }); t.start();注意start()和run()的区别。新手最容易犯的错就是调用run()——这不会启动新线程,只会在当前线程里同步执行一遍任务代码。start()才是告诉 JVM 去创建一个新的执行单元。
Python 的 threading 模块用法类似:
import threading def worker(): print(f"线程执行中:{threading.current_thread().name}") t = threading.Thread(target=worker) t.start()Go 更简单,一个go关键字就够了:
go func() { fmt.Println("goroutine 执行中") }()单看代码量,Go 完胜。但简单意味着容易滥用——很多人随手go func()一把梭,既不管理生命周期,也不处理错误,等到协程泄漏了才来哭。语言再方便,该有的管理意识不能丢。
3.2 线程状态的流转与把控
线程不是创建了就一直跑。理解线程的状态机,是排查并发问题的基本功。以 Java 的线程状态为例,大致有这几态:NEW(新建)、RUNNABLE(可运行)、BLOCKED(阻塞)、WAITING(等待)、TIMED_WAITING(限时等待)、TERMINATED(终止)。
状态之间的流转,对应着代码里的具体操作。调用start(),NEW 变 RUNNABLE;线程获取不到锁,RUNNABLE 变 BLOCKED;调用wait()或join(),进入 WAITING;调用sleep(毫秒)或wait(timeout),进入 TIMED_WAITING;任务执行完,进入 TERMINATED。
排查问题的时候,如果你能一眼看出线程卡在哪个状态,方向就对了大半。比如线程大量停在 BLOCKED,说明锁竞争激烈,优先查临界区的加锁范围是不是太大;线程停在 WAITING 没人唤醒,十有八九是notify()丢在了错误的时机;线程一直在 RUNNABLE 但任务不前进,可能是死循环或者线程饥饿。
这里我想专门说一个坑:sleep()不会释放锁,wait()会。很多人在持有锁的代码块里调用sleep()模拟耗时操作,结果其他线程全被堵在锁外,性能直线下降。如果你需要"暂停"而且不希望霸占锁,用wait()而不是sleep(),这是经验之谈,也是面试常考点。
还有一个线上排查必备的技能——拿线程 dump。Java 用jstack命令可以打印当前 JVM 里所有线程的状态和堆栈,Python 可以在程序里注册信号处理器,动态打印所有线程的堆栈信息。看到线程 dump 的那一刻,很多悬案直接就有了答案。
4. 共享资源的竞争与同步机制
4.1 竞态条件是怎么产生的
竞态条件(Race Condition)是并发编程里最阴险的问题。它的定义是:多个线程同时访问同一份共享数据,且至少有一个线程在写入,结果取决于线程的执行顺序——而这个顺序是不确定的。这一天执行对,换一天执行错,不是运气问题,是代码里存在未同步的访问路径。
我用一个最经典的例子来解释。假设有一个计数器,两个线程各执行 10000 次自增:
public class Counter { private int count = 0; public void increment() { count++; // 不是原子操作! } }count++看起来是一行代码,但在 CPU 层面它分了三步:从内存读取 count 到寄存器、寄存器加一、把结果写回内存。如果线程 A 读到 count=5 后还没来得及写回,线程 B 也读到了 count=5,两个线程各自加一后写回,count 最终是 6 而不是 7。丢了一次更新,而且整个过程没有任何报错。
这就是竞态条件可怕的地方——它不崩溃、不报错,只是数据悄悄变错。在并发量低的时候可能跑几个月都碰不上一次,一旦流量上来,问题就像火山爆发一样集中喷涌。我在项目里排查过类似问题,现象是统计数据偶尔少几条,从业务和代码层面看都找不出毛病,最后用并发测试工具高频压测才复现出问题。
有一种误判需要提醒:很多人觉得加个volatile就能解决,因为volatile能保证可见性,但它不保证原子性。volatile解决的是"一个线程修改了变量,另一个线程能不能立刻看到"的问题,解决不了"多个线程同时修改"的问题。把count声明成volatile,竞态还在,因为三步操作仍然可能被交错执行。
正确的解法是用原子类或者加锁。原子类的底层依赖 CPU 的 CAS(Compare-And-Swap)指令,能在硬件层面保证"检查-更新"的原子性:
private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); }理解竞态条件的产生机制,比你背十个解决方法都有用。因为竞态不止出现在计数这种简单的场景,它还会出现在懒加载、缓存更新、状态判断等一系列看起来"很安全"的代码路径里。判断的标准只有一个:有没有多个线程可能同时读改写同一份数据,且没有同步手段兜底。
4.2 锁、原子操作与信号量
同步机制的选择,本质是在性能和正确性之间找平衡。先看三类最核心的手段。
锁(Lock)是最直观的同步方式。Java 里自带synchronized和ReentrantLock,Python 有threading.Lock,C++ 有std::mutex。锁的语义是:同一时刻只能有一个线程进入临界区。锁的问题是开销——获得锁、释放锁都有成本,而且锁竞争激烈时,线程会阻塞等待,影响吞吐量。
原子操作(Atomic Operation)是更轻量的手段。它利用 CPU 提供的原子指令,保证某条修改操作要么全部完成,要么完全不执行。原子操作适合简单的计数、标志位更新、指针交换这类场景,开销远小于锁。但它有局限——只能保护单个变量,没法把"多个变量的组合操作"打包成原子操作。
信号量(Semaphore)用来控制同时访问资源的线程数量。它跟锁的区别是:锁是只允许一个线程进入,信号量允许多个,但数量有上限。这个特性特别适合限流场景——比如数据库连接池只允许最多 10 个连接同时被占用,超出就等待。信号量还有二进制的特殊形态,取值只有 0 和 1,行为和锁几乎一致。
给一个选型速查表:
| 场景 | 推荐手段 | 理由 |
|---|---|---|
| 简单计数器 | 原子类 | 开销小,满足原子性 |
| 保护一段代码逻辑 | 锁 | 灵活控制临界区范围 |
| 控制并发资源数量 | 信号量 | 天然支持上限控制 |
| 一写多读的数据 | 读写锁 | 提升读并发度 |
| 无锁数据结构 | CAS | 高并发下性能最优 |
选型最忌讳的是"一把锁走天下"。全部代码用同一个锁保护,正确性大概率没问题,但性能会被锁竞争拖垮。平时写代码的时候多问一句:这里的共享数据,真的需要锁吗?能不能用原子操作替代?能不能通过设计避免共享——比如把数据按线程分片,让每个线程只操作自己的那一份?减少共享,比优化同步更高级。
5. 实操:一个并发任务的完整落地
5.1 场景设计与代码实现
理论说了一堆,落到实操才见真章。我以"批量拉取用户信息并汇总"这个经典场景为例,走一遍完整的并发实现流程。
场景假设:有 10000 个用户 ID,需要调用外部接口逐个获取用户信息,然后汇总成一份报表。单线程逐个调用,假设每个接口耗时 100ms,总耗时约 10000 × 0.1 = 1000 秒,接近 17 分钟,显然不可接受。并发改造后,目标是把耗时压缩到 30 秒以内。
第一步,确定并发模型。这个任务是典型的 I/O 密集型——大部分时间在等待外部接口响应,适合用线程池(Java)或者协程(Python/Go)。我以 Java 的线程池写法为例:
ExecutorService pool = Executors.newFixedThreadPool(50); List<Future<UserInfo>> futures = new ArrayList<>(); for (String userId : userIds) { futures.add(pool.submit(() -> fetchUserInfo(userId))); } List<UserInfo> result = new ArrayList<>(); for (Future<UserInfo> future : futures) { result.add(future.get()); // 按顺序收集结果 } pool.shutdown();第二步,算清楚线程数。I/O 密集型场景有一个近似公式:线程数 ≈ CPU 核心数 × (1 + 平均等待时间 / 平均计算时间)。假设接口等待平均 100ms,本地计算平均 5ms,8 核机器上算出来约 8 × (1 + 20) = 168。但实际还要考虑外部接口的承受能力——50 个并发已经能给外部服务造成压力,168 个并发很可能把对方打挂。所以最终线程数应以双方都能接受为准则,我这里取 50。
第三步,处理异常。future.get()会抛出异常,如果某个请求失败了,直接抛出来会导致整个流程中断。实际项目里更应该做的是捕获异常、记录失败 ID、继续处理其他请求:
List<UserInfo> result = new ArrayList<>(); List<String> failedIds = new ArrayList<>(); for (int i = 0; i < futures.size(); i++) { try { result.add(futures.get(i).get(5, TimeUnit.SECONDS)); } catch (Exception e) { failedIds.add(userIds.get(i)); // 只记录,不中断 } }完整实现跑下来,10000 个请求按 50 并发,理想耗时为 10000 / 50 × 0.1 = 20 秒,加上了线程调度和异常处理的额外开销,实测约 25 秒左右。相比 17 分钟,提升超过 40 倍。
5.2 并发度该怎么定
这是并发编程里最容易被忽视、也最影响最终效果的问题。并发度设大了,资源被耗尽、外部服务被压垮;设小了,任务跑得慢,形同没优化。
我的经验是分三类来定:
CPU 密集型任务的并发度,设为 CPU 核心数 + 1 到 2 就够了。因为计算任务本身就在占用 CPU,开太多线程反而会因为频繁切换而变慢。多出来的 1 到 2 个线程,是为了应对某些线程偶发阻塞的情况。
I/O 密集型任务的并发度,理论上可以设得很大,但实际上受三个因素限制:本地内存(每个线程都要栈空间)、外部服务的承受能力、操作系统的线程数上限。在我的经验里,先按"等待时间 ÷ 计算时间 × 核心数"算出理论值,再砍一半作为初始值,等压测结果出来再逐步上调。一上来就拉满的,基本都会在压测阶段翻车。
动态任务(任务数量波动大)的并发度,不要写死。Java 里用ThreadPoolExecutor的corePoolSize和maxPoolSize配合有界队列来控制;Go 里常用带缓冲的 channel 配合固定数量 worker。原则是:并发度能跟随负载自动伸缩,而不是撞到上限才被动的调节。
针对外部接口,我强烈建议先做一次小流量压测。比如先开 20 个并发跑 1000 个请求,记录响应时间和错误率,再逐步增加并发。你能看到明显的拐点——响应时间开始急剧上升的那个并发数,基本就是该服务的最优并发度。这个数据比任何公式都贴近实际。
6. 常见问题与排查技巧实录
6.1 死锁的排查思路
死锁的成因,经典教材总结为四个必要条件:互斥、持有并等待、不可剥夺、循环等待。代码层面最常见的触发方式是嵌套锁——线程 A 持有锁 1 去等锁 2,线程 B 持有锁 2 去等锁 1,两边互不相让,程序卡死。
排查死锁的第一步,不是看代码,而是看线程 dump。Java 里执行jstack <pid>,输出中会明确标出Found one Java-level deadlock以及参与死锁的线程和锁的信息。Python 可以用faulthandler模块或者发送SIGQUIT信号来 dump 所有线程。看到 dump 的那一瞬间,谁拿了哪把锁、在等哪把锁,一目了然。
修复死锁的手段按优先级排:最推荐的是调整加锁顺序,让所有线程都按同一个顺序获取锁,循环等待自然消失;其次是用tryLock带超时获取锁,获取失败就释放已持有的锁并稍后重试;再不行就缩小锁的粒度,避免持有大锁去等小锁。我实际经验中,绝大多数死锁都源于"加锁顺序不统一",改掉这一个问题,死锁基本绝迹。
6.2 线程安全集合的选用
日常开发里,很多并发问题其实出在集合类的使用上。用的最多的坑就是HashMap在多线程下被并发修改——轻则数据错乱,重则 CPU 100% 甚至死循环(JDK 7 时代扩容时的经典 bug)。
正确做法是按场景选集合。Java 里,ConcurrentHashMap是并发环境下的默认选择,它用分段锁和 CAS 把读写性能优化到很接近普通 HashMap。Python 里的dict本身在单解释器下受 GIL 保护,多线程并发读是安全的,但涉及"读-改-写"组合操作时依然要加锁。Go 里官方提供的sync.Map适合读多写少的场景,但常规情况下普通的 map 加锁反而性能更好。
我见过一个反面教材:某团队为了"并发安全",把并发读的集合替换成了Collections.synchronizedMap,结果所有读操作都被迫串行化,性能直接下降一个量级。其实读多写少的场景完全可以用ConcurrentHashMap,或者用读写锁,根本不需要对所有读加互斥锁。选集合之前,先想清楚读多还是写多、有没有组合操作,再决定用什么。
6.3 性能问题的定位
并发程序的性能问题,现象往往是 CPU 飙高、内存上涨、响应变慢。定位的关键是工具链。
Linux 环境下,top -H -p <pid>可以列出进程内的所有线程及其 CPU 占用,找到那个 CPU 爆表的线程 ID,转成十六进制后,用jstack或者pstack找到对应的代码位置。这一套组合拳能定位到大多数 CPU 密集型的性能瓶颈。
锁竞争导致的性能问题,单看 CPU 占用不一定看得出来。JVM 可以用jstat观察线程状态,配合jstack反复采样,数一数有多少线程卡在 BLOCKED。大量线程阻塞在同一个锁上,说明这个锁的竞争太激烈,下一步就该考虑锁的粒度是不是太大了。
另外分享一个我自己常用的排查技巧:给关键代码块加耗时统计。不一定要上 APM 这种全套监控系统,简单地在临界区入口和出口打点记录耗时、在线程级别汇总,就能很快发现哪些地方的锁持有时间异常地长。很多性能问题不是并发模型错了,而是某一个慢操作霸占了锁太久,拖累了所有线程。定位到那个慢操作,问题就解决了一半。
这篇文章从并发的基础概念讲到线程管理,再到同步机制和实操落地,最后给了排查思路。按我个人的经验,并发编程最难的部分不是语法,而是思维方式的转变——从"程序是顺序执行的"转变成"程序可能有多个执行流在交错运行"。这个转变一旦完成,你再回头看并发相关的代码,就像多了一双眼睛,很多以前看不懂的现象都能对号入座了。下一篇系列文章,我会专门聊线程池的调优细节和消息队列在并发架构里的应用,如果你在实际项目里碰到过什么诡异的并发问题,不妨带着问题来对照,效果会更直接。