欢迎光临
我们一直在努力

Python 彻底搞懂进程和线程(一)

Python 并发入门:彻底搞懂进程、线程、GIL、锁与线程池

在学习 Python 并发编程时,很多人一开始都会被几个概念绕晕:

  • 进程是什么?
  • 线程是什么?
  • 多线程和多进程有什么区别?
  • 为什么 Python 有 GIL?
  • 什么是线程安全?
  • 为什么加锁能解决问题?
  • 死锁又是怎么发生的?
  • 线程池到底解决了什么问题?

本文会从一个“模拟文件下载”的例子开始,逐步讲清楚 Python 并发编程中的核心概念。

1. 进程和线程的基本概念

在操作系统中,进程和线程是并发编程的两个基础概念。

简单来说:

进程是操作系统进行资源分配和调度的基本单位。

线程是 CPU 内核调度和执行的基本单位。

一个程序运行起来后,通常会对应一个进程。

而一个进程内部,可以有一个或多个线程。

可以这样理解:

进程:一个正在运行的程序
线程:进程内部真正执行任务的执行流

比如浏览器是一个进程,浏览器里可能同时有多个线程负责页面渲染、网络请求、事件监听等任务。

2. 不使用并发时的下载任务

假设我们有一个下载函数:

import time

def download(file_name):
print("正在下载…")
time.sleep(2)
print(f"{file_name} 下载完成")

这里用 time.sleep(2) 模拟下载耗时 2 秒。

如果我们不用多线程,连续下载 3 个文件:

for i in range(3):
download(i)

执行过程是:

下载第 1 个文件,等待 2 秒
下载第 2 个文件,等待 2 秒
下载第 3 个文件,等待 2 秒

总耗时大约是 6 秒。

这种执行方式是串行的。

也就是说,前一个任务没有完成,后一个任务就不能开始。

3. 使用 threading 实现多线程

Python 中可以使用 threading 模块创建线程。

示例代码如下:

import threading
import time

def download(file_name):
time.sleep(2)
print(f"{file_name} 下载完成")

for i in range(3):
t = threading.Thread(target=download, args=(i,))
t.start()

这里的核心是:

threading.Thread(target=download, args=(i,))

它表示创建一个线程,让这个线程去执行 download 函数。

args=(i,) 表示传给 download 的参数。

注意,只有一个参数时也要写成元组形式:

args=(i,)

不能写成:

args=(i)

因为 (i) 只是普通表达式,不是元组。

调用:

t.start()

表示让线程进入就绪状态,等待 CPU 调度执行。

4. 使用 multiprocessing 实现多进程

除了多线程,还可以使用多进程。

Python 提供了 multiprocessing 模块:

import multiprocessing

if __name__ == "__main__":
for i in range(3):
p = multiprocessing.Process(target=download, args=(i,))
p.start()

这里需要特别注意:

if __name__ == "__main__":

在 Windows 系统下使用多进程时,这个判断非常重要。

如果不加,子进程在启动时可能会重复执行主模块代码,导致进程不断创建,程序行为异常。

5. 多线程和多进程该怎么选

既然多线程和多进程都能实现并发,那么应该怎么选?

这就要引入 Python 中一个非常重要的概念:GIL。

GIL 的全称是 Global Interpreter Lock,也叫全局解释器锁。

在 CPython 解释器中,GIL 会让同一个进程内,同一时刻只有一个线程真正执行 Python 字节码。

这意味着:

Python 多线程并不能让多个线程同时在多个 CPU 核心上执行 Python 代码。

因此,对于 CPU 密集型任务,多线程往往不能充分利用多核 CPU。

CPU 密集型任务包括:

  • 大量数学计算;
  • 图像处理;
  • 视频编码;
  • 大规模数据计算;
  • 加密解密。

这类任务更适合使用多进程。

而对于 IO 密集型任务,多线程仍然很有用。

IO 密集型任务包括:

  • 文件读写;
  • 网络请求;
  • 数据库查询;
  • 下载文件;
  • 调用外部接口。

因为 IO 操作大多数时间都在等待,线程可以在等待期间切换去做别的任务。

简单总结:

任务类型推荐方式
IO 密集型 多线程
CPU 密集型 多进程

6. start 和 join 的作用

创建线程以后,最常见的两个方法是:

  • start();
  • join()。

start() 表示启动线程:

t.start()

它的含义是:线程已经准备就绪,可以被 CPU 调度。

join() 表示等待线程执行完毕:

t.join()

它通常用来阻塞主线程,让主线程等待子线程全部结束后再继续执行。

示例:

threads = []

start = time.time()

for i in range(3):
t = threading.Thread(target=download, args=(i,))
threads.append(t)
t.start()

for t in threads:
t.join()

end = time.time()

print(f"总耗时:{end start}")

如果不调用 join(),主线程可能不会等待所有子线程结束就继续往下走。

在需要统计总耗时、收集结果或确保任务完成时,join() 很重要。

7. 设置和获取线程名称

线程也可以有名字。

可以手动设置线程名:

t = threading.Thread(target=download, args=("file.txt",))
t.name = "下载线程"
t.start()

在线程内部,可以通过下面的方式获取当前线程:

threading.current_thread().name

例如:

def download(file_name):
time.sleep(2)
print(f"{file_name} 下载完成")
print(threading.current_thread().name)

给线程命名在调试并发程序时很有帮助。

当多个线程同时打印日志时,线程名可以帮助我们判断是哪一个线程执行了某段逻辑。

8. 自定义线程类

除了直接使用 threading.Thread,也可以通过继承的方式创建线程类。

要自定义线程类,需要继承 threading.Thread,并重写 run() 方法:

import threading
import time

class MyDownloadThread(threading.Thread):
def run(self):
time.sleep(2)
file_name = self._args[0] if self._args else None
print(f"{file_name} 下载成功")

使用方式:

for i in range(3):
t = MyDownloadThread(args=(i,))
t.start()

当调用 start() 时,线程内部会自动执行 run() 方法。

不过在实际开发中,如果逻辑不是特别复杂,直接使用:

threading.Thread(target=函数名, args=参数)

通常已经够用了。

自定义线程类更适合线程本身有较多状态、方法和内部逻辑的场景。

9. 什么是线程安全问题

多线程虽然能提高 IO 密集型任务的效率,但也会带来新的问题:线程安全。

假设有一个抢票业务:

  • 一共有 1000 张票;
  • 3 个窗口同时抢票;
  • 每抢到一张票,总票数减 1。

代码大致如下:

total_tickets = 1000
num_tickets = [0, 0, 0]

def buy_ticket(name):
global total_tickets

while True:
current_count = total_tickets

if current_count <= 0:
break

time.sleep(0)

new_count = current_count 1
total_tickets = new_count

num_tickets[name] += 1
print(f"窗口 {name} 抢到一张票,剩余:{total_tickets}")

问题出在这几步不是一个不可分割的整体:

current_count = total_tickets
new_count = current_count 1
total_tickets = new_count

多个线程可能同时读到相同的票数。

比如两个线程都读到还剩 1 张票,然后都认为自己可以买,结果就可能出现超卖。

这类多个线程同时访问和修改共享数据导致的问题,就是线程安全问题。

10. 使用锁解决线程安全问题

解决共享资源竞争,最常见的方法是加锁。

Python 中常用的锁包括:

  • threading.Lock();
  • threading.RLock()。

基本思路是:

同一时间只允许一个线程进入关键代码区。

示例:

lock = threading.RLock()

def buy_ticket(name):
global total_tickets

with lock:
while True:
current_count = total_tickets

if current_count <= 0:
break

new_count = current_count 1
total_tickets = new_count

num_tickets[name] += 1
print(f"窗口 {name} 抢到一张票,剩余:{total_tickets}")

with lock: 的作用是:

  • 进入代码块时自动获取锁;
  • 离开代码块时自动释放锁。

相比手动写:

lock.acquire()
lock.release()

使用 with 更安全。

因为即使代码块中出现异常,锁也会被正确释放。

不过需要注意,锁的范围要设计好。

如果锁的范围太小,可能保护不住共享数据。

如果锁的范围太大,又会让多线程退化成串行执行,降低并发效率。

11. Lock 和 RLock 的区别

Lock 是普通锁。

一个线程获取锁以后,在释放之前,其他线程不能再获取这把锁。

RLock 是可重入锁。

同一个线程可以多次获取同一把锁,但也需要释放相同次数。

简单理解:

  • Lock:同一个线程重复获取同一把锁可能会把自己卡住;
  • RLock:允许同一个线程重复进入被同一把锁保护的代码。

如果没有嵌套加锁需求,普通 Lock 通常就够了。

如果函数之间存在互相调用,并且它们都需要同一把锁,RLock 会更安全。

12. 死锁是怎么发生的

锁能解决线程安全问题,但锁用不好也会产生新的问题:死锁。

看下面这个例子:

lock1 = threading.Lock()
lock2 = threading.Lock()

def t1():
lock1.acquire()
time.sleep(1)
lock2.acquire()
print("t1")

def t2():
lock2.acquire()
time.sleep(1)
lock1.acquire()
print("t2")

两个线程分别执行:

thread1 = threading.Thread(target=t1)
thread2 = threading.Thread(target=t2)

thread1.start()
thread2.start()

thread1.join()
thread2.join()

程序会卡住。

原因是:

t1 拿到了 lock1,等待 lock2
t2 拿到了 lock2,等待 lock1

双方都拿着对方想要的资源,又都不释放自己手里的资源。

于是程序就永远等待下去。

这就是死锁。

需要注意:这类代码如果直接运行,程序会一直阻塞,后面的代码也不会继续执行。

13. 如何避免死锁

避免死锁可以从几个方面入手。

第一,尽量减少同时持有多把锁。

如果一个任务只需要一把锁,就不要设计多把锁。

第二,多把锁要保持固定的获取顺序。

比如所有线程都先拿 lock1,再拿 lock2,就不会出现互相等待。

错误示例:

线程 A:先拿 lock1,再拿 lock2
线程 B:先拿 lock2,再拿 lock1

更好的方式:

所有线程:都先拿 lock1,再拿 lock2

第三,使用 with 管理锁。

with lock:
# 操作共享资源

这样可以降低忘记释放锁的风险。

第四,必要时使用超时机制。

locked = lock.acquire(timeout=3)

如果在指定时间内拿不到锁,就可以放弃等待或执行补偿逻辑。

14. 线程不是越多越好

很多人刚学多线程时,会有一个误解:

线程越多,程序越快。

实际上并不是。

线程太多会带来额外开销:

  • 线程创建和销毁需要成本;
  • CPU 在线程之间切换需要成本;
  • 线程过多会导致调度频繁;
  • 共享资源竞争会更加严重;
  • 程序更难调试。

所以线程数量要控制。

这就引出了线程池。

15. 使用 ThreadPoolExecutor 管理线程

Python 提供了 concurrent.futures.ThreadPoolExecutor 来管理线程池。

线程池的作用是:

限制最大线程数量,并复用线程执行任务。

示例:

from concurrent.futures import ThreadPoolExecutor

def download(file_name):
time.sleep(2)
print(f"{file_name} 下载完成")

pool = ThreadPoolExecutor(max_workers=3)

for i in range(10):
pool.submit(download, i)

pool.shutdown(wait=True)

其中:

ThreadPoolExecutor(max_workers=3)

表示最多同时运行 3 个工作线程。

pool.submit(download, i)

表示把任务提交给线程池。

pool.shutdown(wait=True)

表示等待线程池里的任务全部执行完成,然后关闭线程池。

在实际开发中,线程池通常比手动创建大量线程更推荐。

因为它更容易控制并发数量,也更方便管理任务生命周期。

16. 小结

本文从模拟文件下载开始,逐步讲解了 Python 并发编程中的几个核心概念。

核心结论如下:

  • 进程是资源分配的基本单位;
  • 线程是 CPU 调度执行的基本单位;
  • threading 可以实现多线程;
  • multiprocessing 可以实现多进程;
  • IO 密集型任务适合多线程;
  • CPU 密集型任务更适合多进程;
  • start() 用于启动线程;
  • join() 用于等待线程结束;
  • 多线程访问共享数据可能出现线程安全问题;
  • 锁可以保护共享资源;
  • 锁使用不当可能导致死锁;
  • 线程不是越多越好,线程池可以控制并发数量。

如果用一句话总结:

Python 并发编程的关键,不只是“让任务同时跑”,更重要的是理解任务类型、共享资源和并发控制。

赞(0)
未经允许不得转载:171主机测评 » Python 彻底搞懂进程和线程(一)
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址