多线程编程

使用 Python 的同学可能都不喜欢多线程编程,或者说是不习惯多线程编程,可能是因为很多讲 Python 各种应用的书籍都不喜欢使用线程,更多得是使用协程或者是进程。但是呢,我认为你要了解为什么不用线程,你得知道什么是线程,有什么毛病,为什么大家都不喜欢用它。

本文就以作者本人的角度出发,谈谈个人对 Python 线程的看法,以及个人对 Python 线程的理解和使用,希望能够给大家展示一下 Python 中线程的好与坏,能够给读者提供一些帮助,下面废话不多说,直接如正文。

什么是线程

哈哈,每当介绍概念得时候总感觉占得高度有点高,但是吧,你要给一个东西下个定义要不高度不站高点还真不好下定论,为了兼顾彼此,我喜欢以通俗易懂的语言来阐述一些事情。要说线程是啥,我先给个概念,线程是 CPU 调度的最小单位,也就是说 CPU 每次切换不是切换进程,而且切换的线程,注意,也不是协程,所以 CPU 密集型的应用用协程没好处。

线程有自己的开始、执行过程和结束,和进程有区别的是,线程没有独占的地址空间、内存、数据栈和其他运行轨迹的辅助数据,但是,线程有当前运行位置的指针,这个还是要有的。并不是说线程没有独立的地址空间等就很废了,恰恰相反,因为同一进程中不同线程是共享地址空间和内存的,所以他们可以通过这些共享区域进行通信和交互,而进程却是不行的,所以线程的切换比进程的切换快得多了去了。

Python 中的线程

在 Python 中,因为 Python 的线程是真正的系统线程,所以调度有时是不受 Python 控制的,但是,我们可以保证的是高优先级的线程肯定是比低优先级的线程先执行的。然后,由于众所周知的 GIL 的缘故,Python 做不到真正的多线程并发,后面我们将会有一些例子来展示这些特点。

Python 提供了好几个用于多线程编程的模块,包括 thread、threading 和 Queue 等;thread 和 threading 模块运行开发人员创建和管理线程,但是,侧重点不同。thread 模块提供的是基本的线程操作以及同步的支持,而 threading 提供的是更高级的线程管理功能。所以为了直接进入主题,我们的多线程都是从 threading 开始的,有一些同步的时候,我们会使用 thread 模块中的一些功能。

线程操作

创建线程

下面开始就以 threading 模块为基础,开始编写一个很简单的线程程序,然后让这个线程跑起来,我们先试试这段代码:

我们将这段代码保存为: example_00.py,然后运行一遍: python3.5 example_00.py,然后应该可以发现输出可能是这样:

all thread done by liuliqiang.info!
thread: Thread-1 runing

当然,可能也会出现这两句文字顺序相反的情况,都是合理的。但是,不知道你会不会想到有没有可能只输出了一句,并没有输出另一句,例如,只输出一句:

all thread done by liuliqiang.info!

然后就完了,没有其他输出了。

守护线程

基于上面的考虑,我们可以尝试一下以下这段代码,其实变化没多大,只是在创建线程的时候多指定了一个参数:daemon=True,然后让子线程等待了一秒钟,我们再来看看输出结果:

我将这段代码保存为 example_01.py,然后再运行一下:python3.5 example_01.py,看看输出的结果:

all thread done by liuliqiang.info!

我们可以发现,确实只输出了一句,而没有输出子线程中的语句,说明子线程还没执行到输出的那句就结束了,这就是守护线程的意义了,如果我们需要确保守护进程确实完成了,那么我们可以调用 join() 方法,这样的话,我们就可以保证守护进程一定会执行完成:

将这段代码保存为 example_02.py,然后再调用试试: python3.5 example_02.py,看看结果:

thread: Thread-1 runing
all thread done by liuliqiang.info!

守护线程完成之后,主进程才会确保结束!

这里对守护线程的特性做了一个简单的介绍,因为守护线程默认情况下是在主线程结束之后马上结束,无论当前处于什么状态,所以有几个点需要注意:

  1. 当只剩下守护线程在执行的时候,Python 程序就会结束,无论守护进程是否执行完毕
  2. 守护线程的初始值都是从创建的线程中继承的
  3. 守护线程会突然得 stop 或者 shutdown,他们所占有的资源(打开的文件/数据库连接...)将不会主动释放
  4. 如果想要优雅得释放守护线程占用的资源,可以将他们创建为非守护线程或者使用类似于 Event 的信号

终止线程

在 Python 中,我们可能会有终止的想法,例如,当我们发现某个条件不满足的时候,可能会想要主动得终止线程。至于怎么做,我们可能会不自觉得去找找文档,看下有没有什么终止线程的方法,然后翻了翻 Python3 的 docs,也没有发现有 stop 或者 terminal 的方法。

可能有过其他经验(例如Java)的同学都会知道,在很多编程语言中,都不推荐使用 stop() 之类的方法来终止线程,因为这和我们前面说的守护线程的情况有点类似,很可能存在不会释放资源等问题,所以对于有关闭线程之类的需求,我们都是以线程自然终止的方式来结束线程。

线程通信

当谈及多线程的时候,就避免不了多个线程之间如何共享数据,如何进行线程之间的数据传输。对于线程来说,因为线程间共享的是进程的空间,所以在多线程之间共享数据也就方便得很多了,例如:全局变量,堆等都可以用于线程共享,所以这个问题不大。

线程同步

当线程通信不成问题的时候,其实就带出了另外一个问题,那就是线程间同步。因为有了数据共享,所以,对于共享数据的处理成了一个比较大的问题,例如,对于同一个打开的文件,哪个线程应该读,哪个线程应该写,当线程A读的时候,不能有其他线程写,这些都是线程同步的范畴。

在 Python 中,线程同步还是有很多方式的,譬如:

这些都是线程间同步的常见方式,但是需要注意的是,这却不是所有的方式,很有很多方式可以进行同步,也可以将这些方式组合起来实现更强大的同步方式。下面就以 Queue 为例子,编写一个常见的 生产者-消费者 的 DEMO:

将这段代码保存为 example_03.py,然后执行看看效果:python3.5 example_03.py, 在我这看到的结果是:

producing object for Q
size now: 1
consumed object from Q, queue size: 0
producing object for Q
size now: 1
consumed object from Q, queue size: 0
producing object for Q
size now: 1
producing object for Q
size now: 2
consumed object from Q, queue size: 1
all done!

当然,因为程序的随机数,你的程序的输出可能有点稍不一样,但是也差不多。这段程序是一个比较简单的生产者-消费者的代码,writer 负责往队列里面放置一些元素,而 reader 则是从队列中获取元素。

这里线程间同步的方式就是 Queue,当队列中有元素时,reader 才可以继续执行下去,否则将一直等待到 writer 写入元素位置,这段代码有个问题就是当 writer 写入的元素不如 reader 读取的次数多时,那么reader 将卡死,整个进程将不会结束。有兴趣的同学可以根据我的描述进行修改,如何保证读和写的数量是一直的,而我也将在 github 上的 example_04.py 给出我的简单写法。

总结

关于 Python 中的多线程本文就做这么多的介绍,由于篇幅和能力的限制,可能很多点都是一笔带过,并没有深入得挖掘,对于这些有价值的深入点,以后会有相关的文章进行一一介绍。同时,正如前面所说,Python 多线程事实上是跑在单核上的,所以有时用多线程反而速度更慢,一般情况下,这都是因为你的任务是 CPU 密集型的,因为线程切换等问题导致时间消耗更多;但是,当你的任务是 I/O 密集型的时候,多线程倒是不错的选择,性能未必是最优的,但是,代码结构倒是比较优的。

Github 代码: https://github.com/yetship/blog_codes/tree/master/multithread_demo

Reference

  1. Core Python
  2. threading — Manage Concurrent Operations Within a Process
  3. Threading
  4. Understanding the Python GIL