2. 线程基础回顾:进程与线程的区别、Python线程的创建方式、线程的生命周期、GIL对UI线程的影响

聊多线程UI卡顿之前,咱们得先把地基打牢。说实话,很多同学写了好几年Python,对线程的理解还停留在「能跑就行」的阶段。但做UI开发不一样——你稍微搞错一点,界面就给你脸色看。

这一节,我带你把这些基础概念彻底捋一遍。放心,不会讲得太枯燥。

2.1 进程 vs 线程:到底差在哪?

先问个问题:你打开一个,再打开一个浏览器,这是几个进程?答案是两个。那你在里同时聊天和刷朋友圈呢?这是同一个进程里的两个线程。

我习惯用一个比喻来解释:进程就像一家餐厅,线程就是里面的厨师。

  • 进程:拥有独立的资源(厨房、食材、餐具)。进程之间互不干扰,一个倒闭了不影响另一个。
  • 线程:共享进程的资源(同一个厨房)。多个厨师可以同时干活,但抢锅铲的时候就会打架。

具体到技术层面,区别是这样的:

对比项 进程 线程
资源开销 大(独立内存空间) 小(共享内存)
创建速度 慢 快
通信方式 IPC(管道、队列等) 直接读写共享变量
稳定性 一个挂了不影响其他 一个挂了整个进程崩
适用场景 CPU密集型任务 I/O密集型任务

核心结论:UI开发中,我们主要用线程来处理耗时操作。因为线程轻量、共享数据方便。但共享也意味着要小心——后面讲线程安全时会细说。

2.2 Python创建线程的三种方式

Python里创建线程其实很简单。我最早学的时候,觉得就一种写法。后来项目做多了才发现,不同场景用不同方式,代码会优雅很多。

方式一:直接使用 threading.Thread

最基础的方式,适合简单任务。

import threading
import time

def download_file(url):
    print(f"开始下载:{url}")
    time.sleep(2)  # 模拟耗时
    print(f"下载完成:{url}")

# 创建线程
t = threading.Thread(target=download_file, args=("http://example.com/file.zip",))
t.start()  # 启动线程
t.join()   # 等待线程结束(可选)
print("主线程继续执行...")

我的习惯:简单任务用这种方式就够了。但如果你需要传递多个参数,或者任务逻辑复杂,建议用下面两种。

方式二:继承 Thread 类

适合需要封装复杂逻辑的场景。我在做一个爬虫项目时用过这种方式,因为每个线程需要维护自己的状态。

import threading
import time

class DownloadThread(threading.Thread):
    def __init__(self, url, save_path):
        super().__init__()
        self.url = url
        self.save_path = save_path
        self.progress = 0  # 自定义属性

    def run(self):
        print(f"开始下载:{self.url}")
        for i in range(1, 101):
            time.sleep(0.05)
            self.progress = i
        print(f"下载完成,保存到:{self.save_path}")

# 使用
t = DownloadThread("http://example.com/file.zip", "./downloads/")
t.start()
t.join()

方式三:使用线程池 ThreadPoolExecutor

这是我现在最常用的方式。你想想看,如果同时开几十个线程,手动管理多累啊。线程池帮你搞定一切。

from concurrent.futures import ThreadPoolExecutor
import time

def download_file(url):
    print(f"开始下载:{url}")
    time.sleep(2)
    return f"{url} 下载完成"

# 创建线程池,最多同时运行3个线程
with ThreadPoolExecutor(max_workers=3) as executor:
    urls = ["url1", "url2", "url3", "url4", "url5"]
    # 提交任务,返回 Future 对象
    futures = [executor.submit(download_file, url) for url in urls]
    
    # 获取结果
    for future in futures:
        print(future.result())

避坑指南:我曾经在项目里直接用 Thread 开了200个线程,结果程序直接卡死。后来才明白,线程不是越多越好。线程池的 max_workers 一般设为 CPU 核心数的 2-4 倍比较合理。

2.3 线程的生命周期

一个线程从出生到死亡,会经历几个阶段。理解这个,对调试UI卡顿问题特别有帮助。

新建 就绪 运行 阻塞 终止 start() CPU调度 时间片用完 I/O、锁等待 条件满足 run()结束

图:线程生命周期状态转换图

简单解释一下:

  • 新建:你 new 了一个 Thread 对象,但还没调用 start()。这时候线程还没真正创建。
  • 就绪:调用了 start(),线程等待 CPU 分配时间片。说白了就是「排队等干活」。
  • 运行:CPU 开始执行线程的 run() 方法。这时候它真的在干活了。
  • 阻塞:线程因为等待 I/O、获取锁、或者 sleep 等原因暂停了。等条件满足后,它会回到就绪状态。
  • 终止:run() 执行完毕,或者抛出了未捕获的异常。线程死亡,无法再启动。

UI开发中的关键点:主线程(UI线程)一旦进入阻塞状态,界面就会卡死。所以耗时操作必须扔到子线程里。这个后面会反复强调。

2.4 GIL:Python多线程的「紧箍咒」

聊Python多线程,GIL是绕不开的话题。很多新手会问:「Python不是有GIL吗?那多线程还有啥用?」

嗯,这个问题我当年也困惑过。咱们把GIL说清楚。

GIL是什么? 全称 Global Interpreter Lock,全局解释器锁。它保证同一时刻只有一个线程在执行 Python 字节码。

说白了,Python 的多线程在 CPU 密集型任务上是「假并行」——多个线程轮流用 CPU,但同一时刻只有一个在工作。

那为什么还要用多线程?因为 I/O 密集型任务不受 GIL 影响。比如网络请求、文件读写、数据库查询,这些操作大部分时间在等待,不占用 CPU。线程在等待时会释放 GIL,让其他线程执行。

任务类型 GIL影响 推荐方案
CPU密集型(计算、图像处理) 严重影响,多线程反而可能更慢 使用 multiprocessing 多进程
I/O密集型(网络、文件、数据库) 影响很小,线程等待时释放GIL 使用 threading 或 asyncio
UI交互(事件处理、界面刷新) 有影响,但可以通过技巧规避 主线程处理UI,子线程做耗时操作

我曾经踩过的坑:有一次我用多线程做图像批量处理,开了8个线程,结果比单线程还慢。后来一查,CPU都被GIL卡住了。换成 multiprocessing 后,速度直接翻了4倍。所以记住:计算密集用多进程,I/O密集用多线程。

2.5 GIL对UI线程的具体影响

回到咱们的课程主题——UI卡顿。GIL在这里扮演什么角色?

想象一下这个场景:你的UI主线程正在处理一个按钮点击事件,这时候子线程在后台下载文件。子线程执行到一半,GIL被主线程抢走了。主线程要刷新界面,但子线程还没释放资源...嗯,这就是卡顿的根源之一。

具体来说,GIL对UI线程的影响体现在:

  • 界面响应延迟:子线程占用GIL时,主线程无法处理用户操作
  • 刷新频率降低:界面重绘需要GIL,但被其他线程占着
  • 死锁风险:如果主线程和子线程互相等待对方释放GIL或锁,程序就僵住了

我的建议:在UI开发中,尽量让子线程做纯I/O操作,避免在子线程中执行大量Python计算。如果非要计算,考虑用 multiprocessing 或者把计算逻辑写成C扩展。另外,千万别在子线程里直接操作UI控件——这不仅是GIL的问题,更是线程安全问题。

好了,基础就讲到这里。这些概念虽然简单,但它们是后面所有内容的地基。下一节咱们会深入聊「为什么多线程会导致UI卡顿」,到时候你会感谢现在认真看基础部分的自己。