☰
多线程并发编程:从线程池、锁到断点续传下载器实战
2026/10/2 5:17:02 网站建设 项目流程

多线程这个词,乍一听像是科班教材里的章节标题,但只要你碰过稍微大一点的系统,基本躲不开它。小到桌面软件里点一下按钮不卡界面,大到后端服务同时接几千个请求,背后都站着同一套东西:把任务拆开、让多条执行流并行推进、再想办法把结果拼回去。很多人第一次接触多线程,是从面试题开始的,背了一堆“线程池参数”“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++的多线程从C++11开始有了标准库支持: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等锁2,B拿着锁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: bytes=0-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_redirects=True, timeout=10) 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": f"bytes={start}-{end}"} resp = requests.get(url, headers=headers, stream=True, timeout=30) resp.raise_for_status() with open(part_path, "wb") as f: for chunk in resp.iter_content(chunk_size=1024 * 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_count=4): 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_workers=thread_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_count=4)

这段代码有几个关键点。第一,分片时最后一个分片要特殊处理,它的结束位置是文件大小减一,因为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": f"bytes={new_start}-{end}"} mode = "ab" if existing > 0 else "wb" resp = requests.get(url, headers=headers, stream=True, timeout=30) resp.raise_for_status() with open(part_path, mode) as f: for chunk in resp.iter_content(chunk_size=1024 * 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、CopyOnWriteArrayList,C++里的原子操作和无锁队列,Python里的queue.Queue,都要知道它们大致是怎么保证安全的,以及适用场景。面试官如果继续问“为什么ConcurrentHashMap比Hashtable快”,那就是在考锁粒度,答“分段锁或CAS加局部锁”基本就到位了。

最后说一点个人体会。多线程这东西,看再多文章都不如自己写一个会出问题的程序,然后亲手把它调稳。我最早学的时候,写了一个多线程计数器,每次结果都不一样,折腾了一下午才搞明白count++不是原子的。后来做下载器,又因为分片边界和临时文件合并踩了坑。这些经历比背面试题有用得多。如果你现在正要上手多线程,建议从一个小工具开始:批量请求、文件分片下载、日志清洗都行,先把线程池、锁、异常处理跑通,再去碰更复杂的无锁结构和内存模型。另外,不管用什么语言,给线程起名字、在日志里带上线程标识、给锁加超时,这三个习惯越早养成越好,它们能在关键时刻帮你省下大量排查时间。

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

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

立即咨询