线程和进程
进程
计算机程序只不过是磁盘中可执行的,二进制(或其它类型)的数据。它们只有在被读取到内存中,被操作系统调用的时候才开始它们的生命期。进程(有时被称为重量级进程)是程序的一次执行。每个进程都有自己的地址空间,内存,数据栈以及其它记录其运行轨迹的辅助数据。操作系统管理在其上运行的所有进程,并为这些进程公平地分配时间。进程也可以通过 fork 和 spawn 操作来完成其它的任务。不过各个进程有自己的内存空间,数据栈等,所以只能使用进程间通讯(IPC),而不能直接共享信息。
线程
线程(有时被称为轻量级进程)跟进程有些相似,不同的是,所有的线程运行在同一个进程中,共享相同的运行环境。它们可以想像成是在主进程或“主线程”中并行运行的“迷你进程”。
线程有开始,顺序执行和结束三部分。它有一个自己的指令指针,记录自己运行到什么地方。线程的运行可能被抢占(中断),或暂时的被挂起(也叫睡眠),让其它的线程运行,这叫做让步。一个进程中的各个线程之间共享同一片数据空间,所以线程之间可以比进程之间更方便地共享数据以及相互通讯。线程一般都是并发执行的,正是由于这种并行和数据共享的机制使得多个任务的合作变为可能。实际上,在单 CPU 的系统中,真正的并发是不可能的,每个线程会被安排成每次只运行一小会,然后就把 CPU 让出来,让其它的线程去运行。在进程的整个运行过程中,每个线程都只做自己的事,在需要的时候跟其它的线程共享运行的结果。
当然,这样的共享并不是完全没有危险的。如果多个线程共同访问同一片数据,则由于数据访问的顺序不一样,有可能导致数据结果的不一致的问题。这叫做竞态条件(race condition)。幸运的是,大多数线程库都带有一系列的同步原语,来控制线程的执行和数据的访问。
另一个要注意的地方是,由于有的函数会在完成之前阻塞住,在没有特别为多线程做修改的情况下,这种“贪婪”的函数会让 CPU 的时间分配有所倾斜。导致各个线程分配到的运行时间可能不尽相同,不尽公平。
多线程编程背景
在多线程(MT)编程出现之前,电脑程序的运行由一个执行序列组成,执行序列按顺序在主机的中央处理器(CPU)中运行。无论是任务本身要求顺序执行还是整个程序是由多个子任务组成,程序都是按这种方式执行的。即使子任务相互独立,互相无关(即,一个子任务的结果不影响其它子任务的结果)时也是这样。这样是不是有点不合逻辑?会不会想要并行运行这些相互独立的子任务呢?这样的并行处理可以大幅度地提升整个任务的效率。这就是多线程编程的目的。
多线程编程对于某些任务来说,是最理想的。这些任务具有以下特点:它们本质上就是异步的,需要有多个并发事务,各个事务的运行顺序可以是不确定的,随机的,不可预测的。这样的编程任务可以被分成多个执行流,每个流都有一个要完成的目标。根据应用的不同,这些子任务可能都要计算出一个中间结果,用于合并得到最后的结果。
运算密集型的任务一般都比较容易分隔成多个子任务,可以顺序执行或以多线程的方式执行。单线程处理多个外部输入源的的任务就不是那么容易了。这种编程任务如果不用多线程的方式处理,则一定要使用一个或多个计时器来实现。
一个顺序执行的程序要从每个 I/O(输入/输出)终端信道检查用户的输入时,程序无论如何也不能在读取 I/O 终端信道的时候阻塞。因为用户输入的到达是不确定的,阻塞会导致其它 I/O 信息的数据不能被处理。顺序执行的程序必须使用非阻塞 I/O,或是带有计时器的阻塞 I/O(这样才能保证阻塞只是暂时的)。
由于顺序执行的程序只有一个线程在运行。它要保证它要做的多任务,不会有某个任务占用太多的时间,而且要合理地分配用户的响应时间。执行多任务的顺序执行的程序一般程序控制流程都很复杂,难以理解。
全局解释器锁
全局解释器锁(GIL)
Python 代码的执行由 Python 虚拟机(也叫解释器主循环)来控制。Python 在设计之初就考虑到要在主循环中,同时只有一个线程在执行,就像单 CPU 的系统中运行多个进程那样,内存中可以存放多个程序,但任意时刻,只有一个程序在 CPU 中运行。同样地,虽然 Python 解释器中可以“运行”多个线程,但在任意时刻,只有一个线程在解释器中运行。对 Python 虚拟机的访问由全局解释器锁(GIL)来控制,正是这个锁能保证同一时刻只有一个线程在运行。在多线程环境中,Python 虚拟机按以下方式执行:
1. 设置 GIL
2. 切换到一个线程去运行
3. 运行:
a. 指定数量的字节码指令,或者
b. 线程主动让出控制(可以调用 time.sleep(0))
4. 把线程设置为睡眠状态
5. 解锁 GIL
6. 再次重复以上所有步骤
在调用外部代码(如 C/C++扩展函数)的时候,GIL 将会被锁定,直到这个函数结束为止(由于在这期间没有 Python 的字节码被运行,所以不会做线程切换)。编写扩展的程序员可以主动解锁 GIL。Python 的开发人员则不用担心在这些情况下你的 Python 代码会被锁住。例如,对所有面向 I/O 的(会调用内建的操作系统 C 代码的)程序来说,GIL 会在这个 I/O 调用之前被释放, 以允许其它的线程在这个线程等待 I/O 的时候运行。 如果某线程并未使用很多 I/O 操作,它会在自己的时间片内一直占用处理器(和 GIL)。也就是说,I/O 密集型的 Python 程序比计算密集型的程序更能充分利用多线程环境的好处。
在 Python 中使用线程
不使用线程
第一个例子中,我们会使用 time.sleep()函数来演示线程是怎样工作的,time.sleep() 需要一个浮点型的参数,来指定“睡眠”的时间(单位秒)。这就意味着,程序的运行会被挂起指定的时间。我们要创建两个“计时循环”。一个睡眠 4 秒种,一个睡眠 2 秒种,分别是 loop0()和 loop1()。 如果我们在一个进程或一个线程中,顺序地执行 loop0()和 loop1(),那运行的总时间为 6 秒。在启动 loop0(),loop1(),和其它的代码时,也要花去一些时间,所以,我们看到的总时间也有可能会是 7 秒钟。
#!/usr/bin/env python
from time import sleep, ctime
def loop0():
print('start loop 0 at:', ctime())
sleep(4)
print('loop 0 done at:', ctime())
def loop1():
print('start loop 1 at:', ctime())
sleep(2)
print('loop 1 done at:', ctime())
def main():
print('starting at:', ctime())
loop0()
loop1()
print('all DONE at:', ctime())
if __name__ == '__main__':
main()
运行结果:
starting at: Mon Aug 21 07:19:03 2017
start loop 0 at: Mon Aug 21 07:19:03 2017
loop 0 done at: Mon Aug 21 07:19:07 2017
start loop 1 at: Mon Aug 21 07:19:07 2017
loop 1 done at: Mon Aug 21 07:19:09 2017
all DONE at: Mon Aug 21 07:19:09 2017
假定 loop0()和 loop1()里做的不是睡眠,而是各自独立的,不相关的运算,各自的运算结果到最后将会汇总成一个最终的结果。这时,如果能让这些计算并行执行的话,那不是可以减少总的运行时间吗?这就是我们现在要介绍的多线程编程的前提条件。
Python 提供了几个用于多线程编程的模块,包括 _thread, threading 和 Queue 等。_thread 和threading 模块允许程序员创建和管理线程。_thread 模块提供了基本的线程和锁的支持,而 threading提供了更高级别,功能更强的线程管理的功能。Queue 模块允许用户创建一个可以用于多个线程之间共享数据的队列数据结构。
_thread 模块
除了产生线程外,thread 模块也提供了基本的同步数据结构锁对象(lock object,也叫原语锁,简单锁,互斥锁,互斥量,二值信号量)。如之前所说,同步原语与线程的管理是密不可分的。
下表列出了常用的thread 模块函数以及 LockType 类型的锁对象的方法:
函数 | 说明 | 所属模块或对象 |
---|---|---|
start_new_thread(function,args, kwargs=None) | 产生一个新的线程,在新线程中用指定的参数和可选的kwargs来调用这个函数。 | thread 模块函数 |
allocate_lock() | 产生一个 LockType 类型的锁对象 | thread 模块函数 |
exit() | 让线程退出 | thread 模块函数 |
acquire(wait=None) | 尝试获取锁对象 | LockType 类型锁对象方法 |
locked() | 如果获取了锁对象返回 True,否则返回 False | LockType 类型锁对象方法 |
release() | 释放锁 | LockType 类型锁对象方法 |
start_new_thread()函数是 thread 模块的一个关键函数,它的语法与内建的 apply()函数完全一样,其参数为:函数,函数的参数以及可选的关键字参数。不同的是,函数不是在主线程里运行,而是产生一个新的线程来运行这个函数。现在我们利用多线程来重写上面的例子:
import _thread
from time import sleep, ctime
def loop0():
print('start loop 0 at:', ctime())
sleep(4)
print('loop 0 done at:', ctime())
def loop1():
print('start loop 1 at:', ctime())
sleep(2)
print('loop 1 done at:', ctime())
def main():
print('starting at:', ctime())
#start_new_thread()要求一定要有前两个参数。所以就算我们想要运行的函数不要参数,也要传一个空的元组。
_thread.start_new_thread(loop0, ())
_thread.start_new_thread(loop1, ())
sleep(6)
print('all DONE at:', ctime())
if __name__ == '__main__':
main()
运行结果:
starting at: Mon Aug 21 22:34:38 2017
start loop 0 at: Mon Aug 21 22:34:38 2017
start loop 1 at: Mon Aug 21 22:34:38 2017
loop 1 done at: Mon Aug 21 22:34:40 2017
loop 0 done at: Mon Aug 21 22:34:42 2017
all DONE at: Mon Aug 21 22:34:44 2017
这个程序的输出与之前的输出大不相同,之前是运行了 6秒,而现在则是 4 秒,是最长的循环的运行时间与其它的代码的时间总和。睡眠 4 秒和2 秒的代码现在是并发执行的。这样,就使得总的运行时间被缩短了。你可以看到,loop1 甚至在 loop0 前面就结束了。程序的一大不同之处就是多了一个“sleep(6)”的函数调用。为什么要加上这一句呢?因为,如果我们没有让主线程停下来,那主线程就会运行下一条语句,显示”all done”,然后就关闭运行着 loop0()和 loop1()的两个线程,退出了。
我们没有写让主线程停下来等所有子线程结束之后再继续运行的代码。这就是我们之前说线程需要同步的原因。在这里,我们使用了 sleep()函数做为我们的同步机制。我们使用 6 秒是因为我们已经知道,两个线程(你知道,一个要 4 秒,一个要 2 秒)在主线程等待 6 秒后应该已经结束了。
应该有更好的管理线程的方法,而不是在主线程里做一个额外的延时 6 秒的操作。因为这样一来,我们的总的运行时间并不比单线程的版本来得少。像这样使用 sleep()函数做线程的同步操作是不可靠的。如果我们的循环的执行时间不能事先确定的话,那怎么办呢?这可能造成主线程过早或过晚退出。这就是锁的用武之地了,下面是使用锁的例子:
import _thread
from time import sleep, ctime
loops = [4,2]
def loop(nloop, nsec, lock):
print('start loop', nloop, 'at:', ctime())
sleep(nsec)
print('loop', nloop, 'done at:', ctime())
lock.release()
def main():
print('starting at:', ctime())
locks = []
nloops = range(len(loops))
for i in nloops:
lock = _thread.allocate_lock()
lock.acquire()
locks.append(lock)
for i in nloops:
_thread.start_new_thread(loop,(i, loops[i], locks[i]))
for i in nloops:
while locks[i].locked():
pass
print('all DONE at:', ctime())
if __name__ == '__main__':
main()
运行结果:
starting at: Mon Aug 21 22:52:34 2017
start loop 1 at: Mon Aug 21 22:52:34 2017
start loop 0 at: Mon Aug 21 22:52:34 2017
loop 1 done at: Mon Aug 21 22:52:36 2017
loop 0 done at: Mon Aug 21 22:52:38 2017
all DONE at: Mon Aug 21 22:52:38 2017
loop()函数替换了我们之前的那几个 loop*()函数。在 loop()函数里,增加了一些锁的操作。一个很明显的改变是,我们现在要在函数中记录下循环的号码和要睡眠的时间。最后一个不一样的地方就是那个锁了。每个线程都会被分配一个事先已经获得的锁,在 sleep()的时间到了之后就释放相应的锁以通知主线程,这个线程已经结束了。
主要的工作在包含三个循环的 main()函数中完成。我们先调用 thread.allocate_lock() 函数创建一个锁的列表,并分别调用各个锁的 acquire()函数获得锁。获得锁表示“把锁锁上”。锁上后,我们就把锁放到锁列表 locks 中。下一个循环创建线程,每个线程都用各自的循环号,睡眠时间和锁为参数去调用 loop()函数。为什么我们不在创建锁的循环里创建线程呢?有以下几个原因:
(1) 我们想到实现线程的同步,所以要让“所有的马同时冲出栅栏”。
(2) 获取锁要花一些时间,如果你的线程退出得“太快”,可能会导致还没有获得锁,线程就已经结束了的情况。
在线程结束的时候,线程要自己去做解锁操作。最后一个循环只是坐在那一直等(达到暂停主线程的目的),直到两个锁都被解锁为止才继续运行。由于我们顺序检查每一个锁,所以我们可能会要长时间地等待运行时间长且放在前面的线程,当这些线程的锁释放之后,后面的锁可能早就释放了(表示对应的线程已经运行完了)。
注意:
出于以下几点考虑,不建议使用 thread 模块。 首先,更高级别的 threading 模块更为先进, 对线程的支持更为完善, 而且使用 thread 模块里的属性有可能会与 threading 出现冲突。其次,低级别的 thread 模块的同步原语很少(实际上只有一个),而 threading 模块则有很多。另一个不要使用 thread 原因是,对于你的进程什么时候应该结束完全没有控制,当主线程结束时,所有的线程都会被强制结束掉,没有警告也不会有正常的清除工作。我们之前说过,至少threading 模块能确保重要的子线程退出后进程才退出。
另一个避免使用 thread 模块的原因是,它不支持守护线程。当主线程退出时,所有的子线程不论它们是否还在工作,都会被强行退出。有时,我们并不期望这种行为,这时,就引入了守护线程的概念threading 模块支持守护线程, 它们是这样工作的: 守护线程一般是一个等待客户请求的服务器,如果没有客户提出请求,它就在那等着。如果你设定一个线程为守护线程,就表示你在说这个线程是不重要的,在进程退出的时候,不用等待这个线程退出。