ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

多线程并发编程:从线程池、锁到断点续传下载器实战

2026/10/2 5:17:13 拓冰建站 浏览量
多线程并发编程:从线程池、锁到断点续传下载器实战 多线程这个词乍一听像是科班教材里的章节标题但只要你碰过稍微大一点的系统基本躲不开它。小到桌面软件里点一下按钮不卡界面大到后端服务同时接几千个请求背后都站着同一套东西把任务拆开、让多条执行流并行推进、再想办法把结果拼回去。很多人第一次接触多线程是从面试题开始的背了一堆“线程池参数”“synchronized和Lock区别”真到写代码的时候还是到处加锁、线程数拍脑袋、出了问题只能重启。我自己踩过的坑也不少早期做一个日志采集模块线程开多了CPU全耗在上下文切换上吞吐反而掉了一半后来做多线程下载和断点续传又因为分片边界没算对合并出来的文件永远差几个字节。这些经验让我意识到多线程不是“会开线程”就完了它是一整套关于资源、调度、同步和排错的方法论。这篇内容会从概念讲到选型再落到代码和调试重点放在那些文档里不常写、但实际干活一定会遇到的地方。适合刚接触并发的新手也适合写了几年业务代码、想系统梳理一遍的同行。1. 多线程到底在解决什么问题1.1 并发和并行不是一回事很多人把并发和并行当成同义词平时聊天无所谓但设计系统的时候混着用方案一定跑偏。并发说的是“同一段时间内多个任务都在推进”它们可能交替使用同一个CPU核心宏观上像同时发生微观上还是串行。并行说的是“同一时刻多个任务真的在各干各的”前提是硬件上有多个核心。你写了一个多线程程序如果机器只有单核那它带来的是并发能力不是并行能力如果机器有八核线程数也配得合理才有可能真正并行。为什么要把这两个词掰开因为它们的优化方向完全不同。并发场景下重点是减少等待、让CPU别闲着比如一个线程等网络IO的时候另一个线程赶紧算点东西并行场景下重点是让每个核心都有活干同时避免它们抢同一份数据。我见过有人在一台双核机器上开了两百个计算线程以为能快两百倍结果光切换线程的开销就把收益吃光了。线程不是越多越好它更像是一张“同时处理任务”的许可证发多了反而要花大量时间决定下一个谁上场。还有一个容易忽略的点并发会引入不确定性。同一段代码单线程跑一百次结果都一样多线程跑一百次可能有一次顺序不同、结果就错了。这不是玄学而是线程调度本身就不保证顺序。所以多线程编程里“能跑”和“稳定跑”之间隔着一整套同步机制后面会专门讲。1.2 多进程和多线程到底怎么选新手最常问的一个问题既然多线程能并发那多进程是不是也能能而且很多时候更省心。多进程是操作系统直接管理的执行单元每个进程有自己独立的地址空间。你开三个进程其中一个崩了另外两个大概率没事因为它们的内存互不相干。多线程共享同一个进程的地址空间通信方便创建一个线程比创建一个进程便宜得多但代价是一荣俱荣、一损俱损一个线程越界写内存整个进程都可能挂掉。我一般用下面这张表来做初步判断对比维度多进程多线程地址空间相互独立共享同一份创建/销毁开销较大较小通信方式管道、共享内存、消息队列等直接读写共享变量需要同步隔离性强一个崩了不影响其他弱一个线程崩溃可能拖垮整个进程适合场景CPU密集型、需要强隔离、任务之间耦合低IO密集型、需要频繁共享数据、追求轻量但这张表只是参考实际选型还要看语言和运行时。比如Python因为全局解释器锁GIL的存在多线程跑CPU密集型任务几乎得不到并行收益这时候多进程往往是更实际的方案而Java的多线程没有这层限制线程池用起来也很成熟大部分场景直接上线程池就行。再比如一个下载器多个分片共享文件句柄和进度信息用多线程比多进程省事但如果每个分片要跑复杂的解压或校验崩一个不想影响整体多进程反而更稳。有一个我自己的判断习惯先问“这些任务之间要不要频繁交换数据”。要优先多线程不要且希望隔离性好优先多进程。再问“主要瓶颈是CPU还是IO”。CPU瓶颈且语言有GIL限制考虑多进程IO瓶颈多线程通常更划算。1.3 线程的生命周期与那笔“看不见的开销”线程不是凭空出现的它有自己的生命周期新建、就绪、运行、阻塞、等待、超时等待、终止。不同语言叫法略有差异但本质差不多。你调用一个创建线程的接口操作系统要给它分配栈空间、登记调度信息线程跑完还要回收这些资源。这个过程本身就要花时间所以“来一个任务开一个线程”是最粗糙的做法任务量一上来系统大部分时间都花在创建和销毁线程上。比创建更隐蔽的是上下文切换。单核上多个线程轮流跑每次换人操作系统都要保存当前线程的寄存器、程序计数器等现场再恢复下一个线程的现场。这个动作很快但不是零成本。线程数越多、切换越频繁这笔开销就越明显。我做过一个粗略的压测同样是处理一批计算任务线程数从4涨到64吞吐一开始上升过了某个点之后开始下降CPU使用率却居高不下。原因就是大量时间花在了切换上真正干活的时间反而少了。那线程数怎么定业界有一些经验公式但别当圣旨。CPU密集型任务线程数通常设为“CPU核心数 1”多出来的一个用来在某个线程偶尔阻塞时替补。IO密集型任务可以用“核心数 × (1 等待时间 / 计算时间)”来估算。比如一个任务计算花10毫秒、等IO花90毫秒那等待时间是计算时间的9倍按四核算就是4 × (1 9) 40个线程。这个数字只是起点真实系统还要看内存、文件句柄、下游服务的承受能力。我自己的做法是先按公式给一个初值再用压测工具逐步调整观察吞吐和延迟的拐点而不是一次拍死。还有一个容易踩的坑线程栈空间。每个线程都要占一块栈内存默认可能1MB到8MB不等。你开一千个线程光栈空间就可能吃掉几个GB。有些场景下可以通过参数调小栈空间但调太小又容易栈溢出。这个平衡点只能根据业务的实际调用深度来试。2. 主流语言的多线程差异比你想的大2.1 Java多线程线程池是绕不过去的坎Java应该是多线程资料最丰富的语言之一面试也最爱问。但抛开面试实际写Java业务代码你几乎不会直接new Thread()而是用线程池。原因很简单线程池把线程的创建、复用、排队、拒绝都管起来了你只需要提交任务。ThreadPoolExecutor的核心参数有七个核心线程数、最大线程数、空闲存活时间、时间单位、任务队列、线程工厂、拒绝策略。这七个参数怎么配直接决定系统在高负载下是排队、是扩容、还是直接拒绝。我见过最常见的错误是核心线程数设得很小任务队列设成一个无界队列。表面上看任务永远不会被拒绝实际上任务在队列里越堆越多延迟越来越高最后内存被撑爆。无界队列等于把风险从“拒绝”转移到了“OOM”这是很多线上事故的根源。另一个常见错误是把最大线程数设得很大以为能提高吞吐结果线程之间抢锁、抢CPU反而更慢。Java里还有一个绕不开的话题synchronized和Lock怎么选。简单说synchronized是语言层面的用起来简单JVM会做锁优化偏向锁、轻量级锁、重量级锁大部分场景够用Lock是显式锁支持可中断、可超时、公平锁、多个条件变量适合需要更细控制的场景。但别为了“显得高级”到处用Lock显式锁忘记在finally里释放就是一个定时炸弹。我个人的原则是能用synchronized就用需要超时或中断再换Lock。Java的内存模型和volatile也是高频考点。volatile保证可见性和一定的有序性但不保证原子性。很多人以为加了volatile就线程安全了结果count照样出错。原因是count不是一步操作它包含读、加、写三个阶段两个线程可能同时读到旧值。这种场景要么用AtomicInteger要么加锁。面试里问“volatile和synchronized的区别”其实就是在问“可见性、原子性、有序性”这三件事各由谁负责。2.2 Python多线程GIL是把双刃剑Python的多线程经常被吐槽“假多线程”根源就是GIL。全局解释器锁让同一时刻只有一个线程能执行Python字节码所以多线程跑纯计算任务性能可能还不如单线程。但这不是说Python多线程没用。IO密集型任务里线程在等待网络、磁盘的时候会释放GIL其他线程就能趁机跑起来所以爬虫、文件处理、请求转发这类场景多线程依然有效。我早期写过一个批量请求接口的脚本单线程跑一千个请求要几分钟换成ThreadPoolExecutor、开20个线程之后时间降到十几秒。这就是IO密集型的典型收益。但如果任务是批量图片缩放、大量数值计算多线程基本白费这时候应该换multiprocessing让每个进程有自己的解释器和GIL才能真正并行。Python里用多线程我推荐两个入口concurrent.futures.ThreadPoolExecutor适合提交一堆独立任务然后收结果threading模块适合需要自己控制线程生命周期、锁和条件的场景。ThreadPoolExecutor的max_workers默认是CPU核心数乘以5但这只是默认值IO等待时间长的时候可以适当调大。注意别调太大线程切换和内存开销一样会反噬。还有一个细节Python的queue.Queue是线程安全的适合做生产者-消费者模型list虽然也能当队列用但多线程下pop(0)和append不是原子操作必须自己加锁。这类“看起来能用、其实有坑”的地方是Python多线程最容易翻车的位置。2.3 C、C#、Qt底层控制和工程化各有各的路C的多线程从C11开始有了标准库支持std::thread、std::mutex、std::condition_variable、std::atomic。它的特点是“自由度高、责任也大”。标准库给了你工具但不帮你兜底线程对象在析构前必须join或detach否则程序直接终止锁忘了释放只能自己排查内存序用错问题可能几个月才复现一次。C适合对性能和资源控制要求高的场景但团队里最好有统一的并发规范否则代码会变得极难维护。C#的多线程更偏工程化。早期用Thread后来Task和async/await成了主流。Task底层也是线程池但配合async/await能把异步代码写得像同步一样直观。C#里还有lock关键字、Interlocked、ConcurrentQueue等工具日常开发基本够用。需要注意的一点是async不等于多线程它只是让出当前线程去干别的真正的执行可能还在同一个线程上。把async和“开线程”画等号是新手常见的误解。Qt的多线程有自己的特点。它提供QThread但更推荐的做法是“把工作对象移到线程里”也就是moveToThread而不是直接继承QThread然后重写run。前者更符合Qt的信号槽模型跨线程通信时信号槽会自动排队不容易出乱子。Qt里还有一个原则UI操作必须在主线程。你在子线程里直接刷新界面程序可能当场崩溃或者界面卡死。正确做法是子线程发信号、主线程接信号再更新UI。这个规则不是Qt独有的大部分GUI框架都这样。调试多线程程序gdb是一个硬核工具。常用命令包括info threads查看所有线程、thread 2切换到二号线程、thread apply all bt打印所有线程的调用栈。死锁的时候把所有线程的栈打出来看看谁持有锁、谁在等锁往往一眼就能定位。gdb对多线程的支持需要程序编译时带上调试信息比如-g否则栈信息会很难看。我在Linux上排查C多线程问题时基本就是“先gdb挂上去thread apply all bt再结合日志看最后一次正常输出”这套流程救过我好几次。3. 共享数据、锁和线程池绕不开的三座山3.1 锁不是越多越安全粒度才是关键多线程最核心的矛盾是线程之间要共享数据但共享数据又容易出错。解决办法通常是加锁但锁用不好性能和安全两头不讨好。互斥锁是最基础的同一时刻只允许一个线程进入临界区。读写锁适合读多写少的场景允许多个读线程同时进入写线程独占。原子操作适合简单的计数、标志位不需要显式加锁性能通常更好。锁的粒度是很多人忽略的点。所谓粒度就是你锁住的范围有多大。锁整个方法可能把不相关的操作也串行化了只锁真正共享的那几行并发度就上去了。但粒度也不是越细越好锁太细会导致加锁次数变多代码复杂度上升还容易漏锁。我一般的做法是先保证正确性锁大一点没关系等压测发现锁竞争成为瓶颈再逐步缩小粒度。还有一个坑是锁的顺序。两个线程分别去拿两把锁顺序相反就可能死锁。比如线程A先拿锁1再拿锁2线程B先拿锁2再拿锁1某一时刻A拿着锁1等锁2B拿着锁2等锁1双方都不松手程序就卡死了。避免死锁最简单的办法是所有线程按同一个全局顺序获取锁。如果做不到就尽量减少同时持有两把锁的场景。3.2 线程池参数怎么配别抄网上那套线程池的核心参数看起来很多但真正需要你拿主意的就几个核心线程数、最大线程数、队列类型和拒绝策略。网上流传的“核心数1”“核心数×2”只是经验值不能直接抄。你要先判断任务类型CPU密集型、IO密集型还是混合型。混合型最麻烦因为一个任务里既有计算又有等待最好的办法是拆开用两个线程池分别处理或者用压测找拐点。队列的选择也很关键。有界队列能防止任务无限堆积但满了之后要触发拒绝策略。拒绝策略一般有四种直接抛异常、丢弃任务、丢弃最老的任务、让提交任务的线程自己执行。最后一种看起来奇怪其实是一种“背压”机制能让上游慢下来避免雪崩。无界队列虽然不会拒绝但会把问题推迟到内存耗尽线上系统慎用。线程工厂也值得关注。默认线程工厂创建出来的线程名字很模糊排查问题时很难区分。建议自定义线程工厂给线程起有意义的名字比如“order-query-pool-1”“file-download-pool-3”。出问题的时候日志里一眼就能看出是哪个池子的事。这个细节很小但真到排查线上问题的时候能省很多时间。3.3 死锁的四个条件和现场排查手法死锁有四个必要条件互斥、持有并等待、不可剥夺、循环等待。这四个条件同时满足才可能死锁。反过来说破坏任意一个就能避免死锁。实际工作中最容易破坏的是“循环等待”也就是前面说的按固定顺序获取锁。另外一个实用技巧是加超时拿不到锁就等一小段时间超时后放弃并释放已持有的锁回退重试。这样即使出现死锁倾向也能自动解开。排查死锁Java可以用jstack把线程栈打出来搜索“deadlock”或者“waiting to lock”。C用gdb的thread apply all bt看每个线程卡在哪个锁上。Python可以用faulthandler或者py-spy看线程栈。不管用哪个工具思路都是一样的找到谁在等、谁在持有、等待关系有没有形成环。很多时候日志里最后一条正常输出的位置就是死锁发生的位置。还有一种不是死锁但很像死锁的情况活锁。线程没有被阻塞但一直在重试、一直失败CPU跑满却没有任何进展。比如两个线程都在等对方先退让结果谁都不退。活锁的排查比死锁难通常要结合性能剖析工具看线程到底在干什么。解决方法是引入随机退避或者调整重试策略。4. 手把手实操多线程加断点续传写一个下载器4.1 为什么下载器天生适合多线程下载文件这件事本质上是“从服务器读数据、写到本地”。如果只开一个线程那就是一个字节流从头读到尾网络稍微抖动一下速度就上不去。多线程下载的思路是把文件切成若干段每个线程负责一段同时从服务器拉取最后合并。这样做的好处是多个连接可以同时传输更容易跑满带宽某一段失败了只需要重试这一段不用从头再来。这就是断点续传的基础。HTTP协议里支持这个玩法的关键字段是Range。客户端请求时带上Range: bytes0-999服务器如果支持就会返回206 Partial Content并带上Content-Range说明返回的是哪一段。如果服务器不支持会返回200和整个文件那多线程分片就无从谈起。所以写下载器之前先发一个HEAD请求或者带Range的GET请求看看响应头里有没有Accept-Ranges: bytes。没有的话老老实实单线程下载。需要提醒的是多线程下载并不总是更快。如果服务器限速、你的带宽本身很小、或者磁盘写入是瓶颈开再多线程也没用。我一般会先单线程测一下速度再开4到8个线程对比找到合适的并发数。盲目开几十个线程反而可能被服务器当成异常流量。4.2 关键代码骨架分片、请求、写入下面用Python写一个简化版的多线程下载器重点看分片逻辑和断点续传。这里用requests和concurrent.futures代码只保留核心部分实际使用还需要补异常处理和日志。import os import requests from concurrent.futures import ThreadPoolExecutor, as_completed def get_file_size(url): resp requests.head(url, allow_redirectsTrue, timeout10) resp.raise_for_status() size int(resp.headers.get(Content-Length, 0)) accept_ranges resp.headers.get(Accept-Ranges, ) return size, accept_ranges.lower() bytes def download_part(url, start, end, part_path): headers {Range: fbytes{start}-{end}} resp requests.get(url, headersheaders, streamTrue, timeout30) resp.raise_for_status() with open(part_path, wb) as f: for chunk in resp.iter_content(chunk_size1024 * 64): if chunk: f.write(chunk) def merge_parts(part_paths, output_path): with open(output_path, wb) as out: for part in part_paths: with open(part, rb) as f: out.write(f.read()) os.remove(part) def multi_thread_download(url, output_path, thread_count4): size, support_range get_file_size(url) if size 0: raise ValueError(无法获取文件大小) if not support_range: print(服务器不支持 Range退化为单线程下载) download_part(url, 0, size - 1, output_path) return part_size size // thread_count ranges [] for i in range(thread_count): start i * part_size end size - 1 if i thread_count - 1 else (start part_size - 1) ranges.append((start, end)) part_paths [f{output_path}.part{i} for i in range(thread_count)] with ThreadPoolExecutor(max_workersthread_count) as executor: futures [] for (start, end), part_path in zip(ranges, part_paths): futures.append(executor.submit(download_part, url, start, end, part_path)) for future in as_completed(futures): future.result() merge_parts(part_paths, output_path) print(下载完成:, output_path) if __name__ __main__: multi_thread_download(http://example.com/test.bin, test.bin, thread_count4)这段代码有几个关键点。第一分片时最后一个分片要特殊处理它的结束位置是文件大小减一因为HTTP的Range是闭区间。如果按平均分片算最后一个分片可能会超出文件末尾请求就会失败。第二每个分片写自己的临时文件合并时按顺序拼接。这样即使某一段失败其他段的结果还在重试时只补失败的那一段。第三thread_count不是越大越好示例给了4实际可以按带宽和服务器承受能力调整。如果要支持真正的“断点续传”还需要记录每个分片的进度。可以在下载前检查临时文件是否存在、大小是多少然后从已有大小继续请求。伪代码是def download_part_resume(url, start, end, part_path): existing os.path.getsize(part_path) if os.path.exists(part_path) else 0 if existing (end - start 1): return new_start start existing headers {Range: fbytes{new_start}-{end}} mode ab if existing 0 else wb resp requests.get(url, headersheaders, streamTrue, timeout30) resp.raise_for_status() with open(part_path, mode) as f: for chunk in resp.iter_content(chunk_size1024 * 64): if chunk: f.write(chunk)这段逻辑的核心是每次开始前先看临时文件已经有多少字节然后只请求剩下的部分。这样即使程序中途退出下次启动也能接着下。4.3 合并校验与异常重试的细节下载完成后别急着删临时文件先做校验。如果服务器提供了Content-MD5或者你自己有文件的哈希值可以算一遍比对。没有哈希值的话至少检查合并后的文件大小是否等于Content-Length。我遇到过因为最后一个分片少写了几百字节文件看起来完整、打开却损坏的情况。后来养成了习惯合并后先比对大小再用校验工具跑一遍。异常重试也有讲究。网络请求可能会超时、返回5xx、或者连接被重置。重试不能无脑循环否则遇到持续故障会把线程卡死。一般做法是设置最大重试次数比如3次每次重试间隔逐渐拉长。如果某个分片重试多次仍然失败就把它标记为失败等其他分片跑完后统一报错。不要因为一个分片失败就取消其他分片那样会浪费已经下载好的数据。还有一个容易忽略的点磁盘IO的并发。多个线程同时写多个临时文件如果磁盘本身是机械硬盘随机写入性能很差多线程下载的收益可能被磁盘拖累。这时候可以考虑加上写入缓冲或者限制同时写入的线程数。我在机械硬盘上做过多线程下载对比线程数从1加到8前期提升明显到8以后就基本平了再加反而因为磁盘寻道变慢。固态硬盘上情况会好很多但也不是没有上限。5. 常见问题、排查技巧和面试高频套路5.1 线程安全问题速查多线程程序出的问题很多都能归到几类竞态条件、死锁、活锁、内存可见性、资源耗尽。下面这张表是我自己整理的速查表遇到问题时可以先按症状对号入座。症状可能原因排查方向结果偶尔不对重启就好竞态条件、共享变量未同步检查共享数据的读写是否加锁或使用原子操作程序卡死CPU不高死锁打印所有线程栈找等待环CPU跑满但没进展活锁、自旋过多、线程数过多看线程在干什么减少自旋或加退避一个线程改了值另一个看不到内存可见性问题检查是否缺少volatile、内存屏障或同步块运行一段时间后创建线程失败线程或文件句柄耗尽检查线程池是否有泄漏连接是否关闭压测时吞吐上不去延迟飙升锁竞争、队列积压、线程数不合理看锁等待时间、队列长度、上下文切换次数竞态条件是最隐蔽的因为它不一定每次都复现。我的经验是只要涉及多个线程读写同一个变量就先假设它有问题然后逐一确认。用volatile、原子类、锁或者线程本地存储都可以关键是要有一套统一的规则别这里加锁那里不加。5.2 用gdb、日志和性能工具定位多线程bug排查多线程问题第一手信息永远是日志。日志里要打线程名或线程ID否则你根本分不清哪条输出是哪个线程写的。我习惯在线程池的线程工厂里给线程起有业务含义的名字比如“download-pool-1”这样日志一眼就能看出问题来自哪个模块。gdb适合排查C/C程序的死锁和卡死。挂上去之后先info threads看有几个线程再thread apply all bt把所有线程的栈打出来。重点看两类栈一类是停在pthread_mutex_lock、std::mutex::lock上的说明它在等锁另一类是持有锁但迟迟不释放的。找到等待关系之后再回头看代码里锁的获取顺序。Java程序用jstack命令很简单jstack pid thread_dump.txt然后在文件里搜“Found one Java-level deadlock”或者“waiting to lock”。Python可以用py-spy dump --pid pid能直接看到每个线程的栈不需要修改代码。性能方面perf、async-profiler、VisualVM都能看线程状态和锁竞争情况选择顺手的就行。日志有一个技巧在关键路径上打“进入临界区”和“离开临界区”并带上时间戳和线程ID。如果发现某个线程进入后长时间没有离开那它大概率卡在里面了。这个方法看起来笨但在没有调试器的生产环境里非常有效。5.3 多线程面试题的高频套路面试里问多线程通常不是要你背概念而是想看你有没有踩过坑、有没有体系。高频题大概分几类线程池参数与拒绝策略、synchronized和Lock的区别、volatile的作用、CAS和AQS、ThreadLocal、死锁的四个条件、多进程和多线程的区别、Python的GIL。回答的时候别只背结论带上一个自己遇到的场景会加分很多。比如问“线程池核心线程数和最大线程数怎么设”你可以先说任务类型再说初值怎么估然后说压测怎么调最后补一句“无界队列的风险”。问“死锁怎么排查”可以说“先抓线程栈找等待环再按固定顺序获取锁或加超时”。问“Python多线程为什么有时候没用”直接点出GIL再补一句“IO密集型依然有效CPU密集型换多进程”。这种答法比干巴巴的定义更有说服力。还有一个容易被追问的点线程安全的数据结构。Java里的ConcurrentHashMap、CopyOnWriteArrayListC里的原子操作和无锁队列Python里的queue.Queue都要知道它们大致是怎么保证安全的以及适用场景。面试官如果继续问“为什么ConcurrentHashMap比Hashtable快”那就是在考锁粒度答“分段锁或CAS加局部锁”基本就到位了。最后说一点个人体会。多线程这东西看再多文章都不如自己写一个会出问题的程序然后亲手把它调稳。我最早学的时候写了一个多线程计数器每次结果都不一样折腾了一下午才搞明白count不是原子的。后来做下载器又因为分片边界和临时文件合并踩了坑。这些经历比背面试题有用得多。如果你现在正要上手多线程建议从一个小工具开始批量请求、文件分片下载、日志清洗都行先把线程池、锁、异常处理跑通再去碰更复杂的无锁结构和内存模型。另外不管用什么语言给线程起名字、在日志里带上线程标识、给锁加超时这三个习惯越早养成越好它们能在关键时刻帮你省下大量排查时间。