目录

  • 一、什么是多线程、多进程、守护线程
  • 1.1 进程
  • 1.2 程序
  • 1.3 线程
  • 1.4 多线程
  • 1.5 守护线程
  • 1.6 进程与线程的区别
  • 1.7 进程与线程的优缺点
  • 二、利用Python进行并行计算
  • 2.1、并行?伪并行?
  • 2.2 GIL
  • 2.2.1 GIL是什么
  • 2.3 如何解决?
  • 2.3.1 使用多进程
  • 三、Python中threading的使用注意事项
  • 3.1 Python多线程的默认情况
  • 3.2 设置守护线程
  • 3.3 join()方法
  • 3.4 同时使用守护线程和join函数
  • 引用


一、什么是多线程、多进程、守护线程

经常被问到python的多线程问题,其实背后是藏着接下来的两个问题:1、python为什么不能多线程;2、怎么用python实现并行。

今天的内容主要围绕上面两个问题展开。要回答这些问题,首先我们要明确几个名词定义。

1.1 进程

狭义定义:进程就是一段程序的执行过程。

广义定义:进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

简单的来讲进程的概念主要有两点:第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。第二,进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时,它才能成为一个活动的实体,我们称其为进程。

进程状态:进程有三个状态,就绪、运行和阻塞。就绪状态其实就是获取了出cpu外的所有资源,只要处理器分配资源就可以马上执行。就绪状态有排队序列什么的,排队原则不再赘述。运行态就是获得了处理器分配的资源,程序开始执行。阻塞态,当程序条件不够时候,需要等待条件满足时候才能执行,如等待i/o操作时候,此刻的状态就叫阻塞态。

1.2 程序

说起进程,就不得不说下程序。先看定义:程序是指令和数据的有序集合,其本身没有任何运行的含义,是一个静态的概念。而进程则是在处理机上的一次执行过程,它是一个动态的概念。这个不难理解,其实进程是包含程序的,进程的执行离不开程序,进程中的文本区域就是代码区,也就是程序。

1.3 线程

通常在一个进程中可以包含若干个线程,当然一个进程中至少有一个线程,不然没有存在的意义。线程可以利用进程所拥有的资源,在引入线程的操作系统中,通常都是把进程作为分配资源的基本单位,而把线程作为独立运行和独立调度的基本单位,由于线程比进程更小,基本上不拥有系统资源,故对它的调度所付出的开销就会小得多,能更高效的提高系统多个程序间并发执行的程度。

当一个进程启动之后,会默认产生一个主线程,因为线程是程序执行流的最小单元,当设置多线程时,主线程会创建多个子线程,在python中,默认情况下(其实就是setDaemon(False)),主线程执行完自己的任务以后,就退出了,此时子线程会继续执行自己的任务,直到自己的任务结束,例子见下面一。

1.4 多线程

在一个程序中,这些独立运行的程序片段叫作“线程”(Thread),利用它编程的概念就叫作“多线程处理”。多线程是为了同步完成多项任务,不是为了提高运行效率,而是为了提高资源使用效率来提高系统的效率。线程是在同一时间需要完成多项任务的时候实现的。

最简单的比喻多线程就像火车的每一节车厢,而进程则是火车。车厢离开火车是无法跑动的,同理火车也不可能只有一节车厢。多线程的出现就是为了提高效率。

1.5 守护线程

守护线程与普通线程的唯一区别是:当所有的线程都是守护线程的时候,主线程就可以退出了;如果还有一个或以上的非守护线程则不会退出。

设置子线程为守护线程时,主线程一旦执行结束,则全部线程全部被终止执行,可能出现的情况就是,子线程的任务还没有完全执行结束,就被迫停止

1.6 进程与线程的区别

进程和线程的主要差别在于它们是不同的操作系统资源管理方式。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。

  1. 简而言之,一个程序至少有一个进程,一个进程至少有一个线程.
  2. 线程的划分尺度小于进程,使得多线程程序的并发性高。
  3. 另外,进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。
  4. 线程在执行过程中与进程还是有区别的。每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
  5. 从逻辑角度来看,多线程的意义在于一个应用程序中,有多个执行部分可以同时执行。但操作系统并没有将多个线程看做多个独立的应用,来实现进程的调度和管理以及资源分配。这就是进程和线程的重要区别。

1.7 进程与线程的优缺点

线程和进程在使用上各有优缺点:线程执行开销小,但不利于资源的管理和保护;而进程正相反。同时,线程适合于在SMP(多核处理机)机器上运行,而进程则可以跨机器迁移。

二、利用Python进行并行计算

2.1、并行?伪并行?

学过操作系统的同学都知道,线程是现代操作系统底层一种轻量级的多任务机制。一个进程空间中可以存在多个线程,每个线程代表一条控制流,共享全局进程空间的变量,又有自己私有的内存空间。

多个线程可以同时执行。此处的“同时”,在较早的单核架构中表现为“伪并行”,即让线程以极短的时间间隔交替执行,从人的感觉上看它们就像在同时执行一样。但由于仅有一个运算单元,当线程皆执行计算密集型任务时,多线程可能会出现 1 + 1 > 2 的反效果。

而“真正的并行”只能在多核架构上实现。对于计算密集型任务,巧妙地使用多线程或多进程将其分配至多个 CPU 上,通常可以成倍地缩短运算时间。

作为一门优秀的语言,python 为我们提供了操纵线程的库 threading。使用 threading,我们可以很方便地进行并行编程。但下面的例子可能会让你对“并行”的真实性产生怀疑。

本文使用的测速函数代码如下:

from __future__ import print_function

import sys
PY2 = sys.version_info[0] == 2

# 因为 Jython 不兼容 Python 3 语法,此处必须 hack 掉 range 以保证都是迭代器版本
if PY2:
    range = xrange  # noqa

from time import time
from threading import Thread


def spawn_n_threads(n, target):
    """
    启动 n 个线程并行执行 target 函数
    """

    threads = []

    for _ in range(n):
        thread = Thread(target=target)
        thread.start()
        threads.append(thread)

    for thread in threads:
        thread.join()


def test(target, number=10, spawner=spawn_n_threads):
    """
    分别启动 1, 2, 3, 4 个控制流,重复 number 次,计算运行耗时
    """

    for n in (1, 2, 3, 4, ):

        start_time = time()
        for _ in range(number):  # 执行 number 次以减少偶然误差
            spawner(n, target)
        end_time = time()

        print('Time elapsed with {} branch(es): {:.6f} sec(s)'.format(n, end_time - start_time))

假设我们有一个计算斐波那契数列的函数:

def fib():

    a = b = 1

    for i in range(100000):
        a, b = b, a + b

此处我们不记录其结果,只是为了让它产生一定的计算量,使运算时间开销远大于线程创建、切换的时间开销。现在我们执行 test(fib),尝试在不同数量的线程中执行这个函数。如果线程是“真并行”,时间开销应该不会随线程数大幅上涨。但执行结果却让我们大跌眼镜:

# CPython,fib
Time elapsed with 1 branch(es): 1.246095 sec(s)
Time elapsed with 2 branch(es): 2.535884 sec(s)
Time elapsed with 3 branch(es): 3.837506 sec(s)
Time elapsed with 4 branch(es): 5.107638 sec(s)

2.2 GIL

2.2.1 GIL是什么

GIL 的全名是 the Global Interpreter Lock (全局解释锁),是常规 python 解释器(当然,有些解释器没有)的核心部件。

可见,这是一个用于保护 Python 内部对象的全局锁(在进程空间中唯一),保障了解释器的线程安全。

这里用一个形象的例子来说明 GIL 的必要性(对资源抢占问题非常熟悉的可以跳过不看):

我们把整个进程空间看做一个车间,把线程看成是多条不相交的流水线,把线程控制流中的字节码看作是流水线上待处理的物品。Python 解释器是工
人,整个车间仅此一名。操作系统是一只上帝之手,会随时把工人从一条流水线调到另一条——这种“随时”是不由分说的,即不管处理完当前物品与
否。
若没有 GIL。假设工人正在流水线 A 处理 A1 物品,根据 A1 的需要将房间温度(一个全局对象)调到了 20 度。这时上帝之手发动了,工人被调到流
水线 B 处理 B1 物品,根据 B1 的需要又将房间温度调到了 50 度。这时上帝之手又发动了,工人又调回 A 继续处理 A1。但此时 A1 暴露在了 50 度的
环境中,安全问题就此产生了。

而 GIL 相当于一条锁链,一旦工人开始处理某条流水线上的物品,GIL 便会将工人和该流水线锁在一起。而被锁住的工人只会处理该流水线上的物品。
就算突然被调到另一条流水线,他也不会干活,而是干等至重新调回原来的流水线。这样每个物品在被处理的过程中便总是能保证全局环境不会突变。

GIL 保证了线程安全性,但很显然也带来了一个问题:每个时刻只有一条线程在执行,即使在多核架构中也是如此——毕竟,解释器只有一个。如此一来,单进程的 Python 程序便无法利用到多核的优势了。

2.3 如何解决?

GIL 是 Python 解释器正确运行的保证,Python 语言本身没有提供任何机制访问它。但在特定场合,我们仍有办法降低它对效率的影响。

2.3.1 使用多进程

线程间会竞争资源是因为它们共享同一个进程空间,但进程的内存空间是独立的,自然也就没有必要使用解释锁了。

许多人非常忌讳使用多进程,理由是进程操作(创建、切换)的时间开销太大了,而且会占用更多的内存。这种担心其实没有必要——除非是对并发量要求很高的应用(如服务器),多进程增加的时空开销其实都在可以接受的范围中。更何况,我们可以使用进程池减少频繁创建进程带来的开销。

下面新建一个 spawner,以演示多进程带来的性能提升:

from multiprocessing import Process


def spawn_n_processes(n, target):

    threads = []

    for _ in range(n):
        thread = Process(target=target)
        thread.start()
        threads.append(thread)

    for thread in threads:
        thread.join()

使用 cpython 执行 test(fib, spawner=spawn_n_processes),结果如下:

# CPython, fib, multi-processing
Time elapsed with 1 branch(es): 1.260981 sec(s)
Time elapsed with 2 branch(es): 1.343570 sec(s)
Time elapsed with 3 branch(es): 2.183770 sec(s)
Time elapsed with 4 branch(es): 2.732911 sec(s)

可见这里出现了“真正的并行”,程序效率得到了提升。

三、Python中threading的使用注意事项

3.1 Python多线程的默认情况

在测试用例中,子线程的任务是停顿5秒后打印线程名字以及时间;主线程的任务是循环打印5次线程名以及时间。

import threading
import time
from datetime import datetime

class MyThread(threading.Thread):
    def __init__(self, id):
        threading.Thread.__init__(self)
        self.id = id

    def run(self):
        time.sleep(5)
        print "子线程动作",threading.current_thread().name, datetime.now()


if __name__ == "__main__":
    t1 = MyThread(999)
    t1.start()
    for i in range(5):
        print "主线程动作",threading.current_thread().name, datetime.now()

得到结果如下:

主线程动作 MainThread 2019-05-20 14:21:51.083000
主线程动作 MainThread 2019-05-20 14:21:51.083000
主线程动作 MainThread 2019-05-20 14:21:51.083000
主线程动作 MainThread 2019-05-20 14:21:51.083000
主线程动作 MainThread 2019-05-20 14:21:51.083000
子线程动作 Thread-1 2019-05-20 14:21:56.083000

可以观察到,主线程和子线程分别在执行,约在主线程执行完5秒后子线程也执行完毕。

3.2 设置守护线程

添加一行守护进程的代码“t1.setDaemon(True)”。

import threading
import time
from datetime import datetime


class MyThread(threading.Thread):
    def __init__(self, id):
        threading.Thread.__init__(self)
        self.id = id

    def run(self):
        time.sleep(5)
        print "子线程动作",threading.current_thread().name, datetime.now()


if __name__ == "__main__":
    t1 = MyThread(999)
    t1.setDaemon(True)  # 添加守护线程!
    t1.start()
    for i in range(5):
        print "主线程动作",threading.current_thread().name, datetime.now()

观察结果,发现主线程快速执行完毕,但是由于子线程耗时较长,主线程执行完的时候子线程还未结束,不过主线程执行完直接结束了程序,不会等待子线程。

主线程动作 MainThread 2019-05-20 14:38:17.270000
主线程动作 MainThread 2019-05-20 14:38:17.270000
主线程动作 MainThread 2019-05-20 14:38:17.270000
主线程动作 MainThread 2019-05-20 14:38:17.270000
主线程动作 MainThread 2019-05-20 14:38:17.270000

3.3 join()方法

现在,在3.1的基础上,我们把join()方法加进去(其他代码不变),看看有什么不一样:

import threading
import time
from datetime import datetime


class MyThread(threading.Thread):
    def __init__(self, id):
        threading.Thread.__init__(self)
        self.id = id

    def run(self):
        time.sleep(5)
        print "子线程动作",threading.current_thread().name, datetime.now()


if __name__ == "__main__":
    t1 = MyThread(999)
    t1.start()
    t1.join()  # 添加join函数!
    for i in range(5):
        print "主线程动作",threading.current_thread().name, datetime.now()

只是添加了join函数一行代码,我们发现主线程和子线程执行的顺序就改变了。线程 t1

子线程动作 Thread-1 2019-05-20 14:27:36.104000
主线程动作 MainThread 2019-05-20 14:27:36.104000
主线程动作 MainThread 2019-05-20 14:27:36.104000
主线程动作 MainThread 2019-05-20 14:27:36.105000
主线程动作 MainThread 2019-05-20 14:27:36.105000
主线程动作 MainThread 2019-05-20 14:27:36.105000

3.4 同时使用守护线程和join函数

那么如果同时使用上述两种函数会怎么样呢?

import threading
import time
from datetime import datetime


class MyThread(threading.Thread):
    def __init__(self, id):
        threading.Thread.__init__(self)
        self.id = id

    def run(self):
        time.sleep(5)
        print "子线程动作",threading.current_thread().name, datetime.now()


if __name__ == "__main__":
    t1 = MyThread(999)
    t1.setDaemon(True)  # 添加守护线程!
    t1.start()
    t1.join()  # 添加join函数!
    for i in range(5):
        print "主线程动作",threading.current_thread().name, datetime.now()

可以看到,主线程一直等待全部的子线程结束之后,主线程自身才结束,程序退出。

子线程动作 Thread-1 2019-05-20 14:41:57.347000
主线程动作 MainThread 2019-05-20 14:41:57.347000
主线程动作 MainThread 2019-05-20 14:41:57.347000
主线程动作 MainThread 2019-05-20 14:41:57.347000
主线程动作 MainThread 2019-05-20 14:41:57.347000
主线程动作 MainThread 2019-05-20 14:41:57.347000