第一章:多线程基础回顾

各位同学好,我是你们的老朋友。今天咱们聊聊多线程的基础知识。说实话,很多人在并发编程上栽跟头,都是因为基础没打牢。我见过太多人一上来就写线程池、搞锁,结果死锁了都不知道怎么死的。

这一章,我会带着大家把三个核心问题搞清楚:进程和线程到底差在哪?Python 那个被人吐槽的 GIL 锁到底是什么鬼?以及 threading 模块怎么用才顺手。

1.1 进程与线程:别再傻傻分不清

先问个问题:你打开一个浏览器,再打开一个记事本,这是进程还是线程?

答案是进程。每个程序启动,操作系统都会给它分配一块独立的内存空间,这就是一个进程。进程之间是隔离的,你浏览器崩了,不会影响我写代码——嗯,这点很重要。

那线程呢?线程是进程里的执行单元。一个进程可以包含多个线程,它们共享进程的内存空间。说白了,线程就是轻量级的执行流。

核心区别一句话总结:

  • 进程:资源分配的最小单位,独立内存,切换开销大
  • 线程:CPU调度的最小单位,共享内存,切换开销小

我在项目中遇到过一个问题:用多进程处理大量IO任务,结果内存直接爆了。为什么?因为每个进程都复制了一份完整的内存空间。后来换成线程,内存占用降了80%。

这里有个表格,方便大家对比:

对比维度 进程 线程
内存隔离 完全隔离 共享
创建开销 大 小
通信方式 IPC(管道、队列等) 直接读写共享变量
适用场景 CPU密集型 IO密集型
Python中的GIL影响 无影响 有影响

1.2 Python GIL 锁:到底锁了个啥?

说到Python多线程,GIL是绕不开的话题。很多人一提到GIL就摇头,觉得它是Python的缺陷。我个人觉得,这其实是个设计取舍问题。

GIL的全称是 Global Interpreter Lock,全局解释器锁。它的作用很简单:保证同一时刻只有一个线程在执行Python字节码。

为什么会这样?因为Python的内存管理不是线程安全的。如果不加GIL,多个线程同时操作引用计数,内存分分钟就崩了。你想想看,CPython解释器在1990年代设计的时候,多核CPU还没普及呢。

避坑指南:

我曾经以为GIL会让多线程完全没用,直到我做了个网络爬虫。爬虫大部分时间在等网络响应,这时候线程会释放GIL,其他线程就能跑。所以对于IO密集型任务,多线程依然有效。

但如果你做的是CPU密集型计算,比如图像处理、数值计算,多线程就尴尬了。多个线程抢一个GIL,反而因为上下文切换变慢。这时候,多进程才是正解。

我画了一张图,帮你理解GIL的工作机制:

GIL 全局锁 线程1(运行中) 线程2(等待) 线程3(等待) 同一时刻,只有一个线程能持有GIL 其他线程在等待队列中排队 IO操作时,线程主动释放GIL,其他线程有机会执行

1.3 threading 模块核心API:手把手教你用

好了,理论说完了,咱们上代码。Python的threading模块是标准库,开箱即用。

1.3.1 创建线程的两种方式

第一种,直接传函数:

import threading
import time

def worker(name):
    print(f"线程 {name} 开始工作")
    time.sleep(2)
    print(f"线程 {name} 结束工作")

# 创建线程
t1 = threading.Thread(target=worker, args=("A",))
t2 = threading.Thread(target=worker, args=("B",))

# 启动线程
t1.start()
t2.start()

# 等待线程结束
t1.join()
t2.join()

print("所有线程执行完毕")

第二种,继承Thread类。我个人习惯用第一种,代码更简洁。但如果你需要在线程里维护状态,继承方式更合适。

class MyThread(threading.Thread):
    def __init__(self, name):
        super().__init__()
        self.name = name
    
    def run(self):
        print(f"线程 {self.name} 开始工作")
        time.sleep(2)
        print(f"线程 {self.name} 结束工作")

t = MyThread("C")
t.start()
t.join()

1.3.2 常用API一览

API 说明 使用频率
Thread(target, args) 创建线程对象 ⭐⭐⭐⭐⭐
start() 启动线程 ⭐⭐⭐⭐⭐
join(timeout) 等待线程结束,可设超时 ⭐⭐⭐⭐
daemon 设为守护线程,主线程退出时自动结束 ⭐⭐⭐
name 线程名称,方便调试 ⭐⭐⭐
ident 线程唯一标识符 ⭐⭐
is_alive() 检查线程是否还在运行 ⭐⭐⭐
active_count() 当前存活的线程数 ⭐⭐
enumerate() 返回所有存活线程列表 ⭐⭐

小技巧:

调试多线程程序时,我习惯给每个线程起个有意义的名字,比如"爬虫线程-1"、"写入线程"。这样看日志的时候,一眼就能知道哪个线程出了问题。

1.3.3 守护线程的坑

守护线程是个好东西,但用不好会出问题。我举个例子:

import threading
import time

def background_task():
    while True:
        print("后台任务运行中...")
        time.sleep(1)

t = threading.Thread(target=background_task)
t.daemon = True  # 设为守护线程
t.start()

time.sleep(3)
print("主线程结束")
# 主线程结束,守护线程自动退出

我曾经踩过的坑:

有一次写日志收集程序,把写日志的线程设成了守护线程。结果主线程一退出,日志还没写完就被强制终止了。后来我改成非守护线程,加上join()确保日志写完再退出。

记住一个原则:守护线程适合做不重要的后台任务,比如心跳检测、缓存清理。重要的数据写入、状态保存,千万别用守护线程。

1.3.4 线程安全与锁

多个线程共享变量时,会出现竞态条件。看这个经典例子:

import threading

counter = 0

def increment():
    global counter
    for _ in range(100000):
        counter += 1

threads = []
for _ in range(10):
    t = threading.Thread(target=increment)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print(f"期望值: 1000000, 实际值: {counter}")
# 实际值往往小于1000000

为什么会这样?因为 counter += 1 不是原子操作。它分三步:读取、加1、写回。线程切换可能发生在任意一步。

解决办法是用锁:

lock = threading.Lock()

def safe_increment():
    global counter
    for _ in range(100000):
        with lock:  # 获取锁
            counter += 1
        # 离开with块自动释放锁

用 with lock 是Python推荐的写法,比手动 acquire() 和 release() 安全得多。万一中间抛异常,锁也能自动释放。

关于锁,记住三点:

  1. 锁的范围要尽量小,只保护关键代码
  2. 避免嵌套锁,容易死锁
  3. 能用 with 就别手动操作

好了,第一章的内容就到这里。多线程的基础概念、GIL的本质、threading模块的核心API,咱们都过了一遍。这些是后面做参数调优和压力测试的基石,一定要理解透彻。

可可精品资料库