进程和线程(下)

本文则介绍下多线程的实现方法,以及分别在哪些情况采用多进程,或者是多线程。

多线程

前面也提到了一个进程至少包含一个线程,其实进程就是由若干个线程组成的线程是操作系统直接支持的执行单元,因此高级语言通常都内置多线程的支持,Python 也不例外,而且 Python 的线程是真正的 Posix Thread ,而不是模拟出来的线程

多线程的运行有如下优点:

  • 使用线程可以把占据长时间的程序中的任务放到后台去处理
  • 用户界面可以更加吸引人,比如用户点击了一个按钮去触发某些事件的处理,可以弹出一个进度条来显示处理的进度。
  • 程序的运行速度可能加快。
  • 一些等待的任务实现上如用户输入、文件读写和网络收发数据等,线程就比较有用了。在这种情况下我们可以释放一些珍贵的资源如内存占用等等。

线程可以分为:

  • 内核线程:由操作系统内核创建和撤销。
  • 用户线程:不需要内核支持而在用户程序中实现的线程。

Python 的标准库提供了两个模块:_threadthreading,前者是低级模块,后者是高级模块,对 _thread 进行了封装。大多数情况只需要采用 threading模块即可,并且也推荐采用这个模块。

这里再次以下载文件作为例子,用多线程的方式来实现一遍:

from random import randint
from threading import Thread, current_thread
from time import time, sleep


def download(filename):
    print('thread %s is running...' % current_thread().name)
    print('开始下载%s...' % filename)
    time_to_download = randint(5, 10)
    sleep(time_to_download)
    print('%s下载完成! 耗费了%d秒' % (filename, time_to_download))


def download_multi_threading():
    print('thread %s is running...' % current_thread().name)
    start = time()
    t1 = Thread(target=download, args=('Python.pdf',), name='subthread-1')
    t1.start()
    t2 = Thread(target=download, args=('nazha.mkv',), name='subthread-2')
    t2.start()
    t1.join()
    t2.join()
    end = time()
    print('总共耗费了%.3f秒' % (end - start))
    print('thread %s is running...' % current_thread().name)


if __name__ == '__main__':
    download_multi_threading()

实现多线程的方式和多进程类似,也是通过 Thread 类创建线程对象,target 参数表示传入需要执行的函数,args 参数是表示传给函数的参数,然后 name 是给当前线程进行命名,默认命名是如 Thread- 1、Thread-2 等等。

此外,任何进程默认会启动一个线程,我们将它称为主线程,主线程又可以启动新的线程,threading 模块中有一个函数 current_thread() ,可以返回当前线程的实例。主线程实例的名字叫 MainThread,子线程的名字是在创建的时候指定,也就是 name 参数。

运行结果:

thread MainThread is running...
thread subthread-1 is running...
开始下载Python.pdf...
thread subthread-2 is running...
开始下载nazha.mkv...
nazha.mkv下载完成! 耗费了5秒
Python.pdf下载完成! 耗费了7秒
总共耗费了7.001秒
thread MainThread is running...
Lock

多线程和多进程最大的不同在于,多进程中,同一个变量,各自有一份拷贝存在于每个进程中,互不影响,而多线程中,所有变量都由所有线程共享,所以,任何一个变量都可以被任何一个线程修改,因此,线程之间共享数据最大的危险在于多个线程同时改一个变量,把内容给改乱了

下面是一个例子,演示了多线程同时操作一个变量,如何把内存给改乱了:

from threading import Thread
from time import time, sleep

# 假定这是你的银行存款:
balance = 0


def change_it(n):
    # 先存后取,结果应该为0:
    global balance
    balance = balance + n
    balance = balance - n


def run_thread(n):
    for i in range(100000):
        change_it(n)


def nolock_multi_thread():
    t1 = Thread(target=run_thread, args=(5,))
    t2 = Thread(target=run_thread, args=(8,))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print(balance)


if __name__ == '__main__':
    nolock_multi_thread()

运行结果:

-8

代码中定义了一个共享变量 balance,然后启动两个线程,先存后取,理论上结果应该是 0 。但是,由于线程的调度是由操作系统决定的,当 t1、t2 交替执行时,只要循环次数足够多,balance 的结果就不一定是0了。

原因就是下面这条语句:

balance = balance + n

这条语句的执行分为两步的:

  • 先计算 balance + n,保存结果到一个临时变量
  • 将临时变量的值赋给 balance

也就是可以看成:

x = balance+n
balance=x

正常运行如下所示:

初始值 balance = 0

t1: x1 = balance + 5 # x1 = 0 + 5 = 5
t1: balance = x1     # balance = 5
t1: x1 = balance - 5 # x1 = 5 - 5 = 0
t1: balance = x1     # balance = 0

t2: x2 = balance + 8 # x2 = 0 + 8 = 8
t2: balance = x2     # balance = 8
t2: x2 = balance - 8 # x2 = 8 - 8 = 0
t2: balance = x2     # balance = 0

结果 balance = 0

但实际上两个线程是交替运行的,也就是:

初始值 balance = 0

t1: x1 = balance + 5  # x1 = 0 + 5 = 5

t2: x2 = balance + 8  # x2 = 0 + 8 = 8
t2: balance = x2      # balance = 8

t1: balance = x1      # balance = 5
t1: x1 = balance - 5  # x1 = 5 - 5 = 0
t1: balance = x1      # balance = 0

t2: x2 = balance - 8  # x2 = 0 - 8 = -8
t2: balance = x2   # balance = -8

结果 balance = -8

简单说,就是因为对 balance 的修改需要多条语句,而执行这几条语句的时候,线程可能中断,导致多个线程把同个对象的内容该乱了。

要保证计算正确,需要给 change_it() 添加一个锁,添加锁后,其他线程就必须等待当前线程执行完并释放锁,才可以执行该函数。并且锁是只有一个,无论多少线程,同一时刻最多只有一个线程持有该锁。通过 threading 模块的 Lock 实现。

因此代码修改为:

from threading import Thread, Lock
from time import time, sleep

# 假定这是你的银行存款:
balance = 0

lock = Lock()

def change_it(n):
    # 先存后取,结果应该为0:
    global balance
    balance = balance + n
    balance = balance - n


def run_thread_lock(n):
    for i in range(100000):
        # 先要获取锁:
        lock.acquire()
        try:
            # 放心地改吧:
            change_it(n)
        finally:
            # 改完了一定要释放锁:
            lock.release()


def nolock_multi_thread():
    t1 = Thread(target=run_thread_lock, args=(5,))
    t2 = Thread(target=run_thread_lock, args=(8,))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print(balance)


if __name__ == '__main__':
    nolock_multi_thread()

但遗憾的是 Python 并不能完全发挥多线程的作用,这里可以通过写一个死循环,然后通过任务管理器查看进程的 CPU 使用率。

正常来说,如果有两个死循环线程,在多核CPU中,可以监控到会占用200%的CPU,也就是占用两个CPU核心。

要想把 N 核CPU的核心全部跑满,就必须启动 N 个死循环线程。

死循环代码如下所示:

import threading, multiprocessing

def loop():
    x = 0
    while True:
        x = x ^ 1

for i in range(multiprocessing.cpu_count()):
    t = threading.Thread(target=loop)
    t.start()

在 4 核CPU上可以监控到 CPU 占用率仅有102%,也就是仅使用了一核。

但是用其他编程语言,比如C、C++或 Java来改写相同的死循环,直接可以把全部核心跑满,4核就跑到400%,8核就跑到800%,为什么Python不行呢?

因为 Python 的线程虽然是真正的线程,但解释器执行代码时,有一个 GIL锁:Global Interpreter Lock任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个 GIL 全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。

GIL是 Python 解释器设计的历史遗留问题,通常我们用的解释器是官方实现的 CPython,要真正利用多核,除非重写一个不带GIL的解释器。

尽管多线程不能完全利用多核,但对于程序的运行效率提升还是很大的,如果想实现多核任务,可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。

ThreadLocal

采用多线程的时候,一个线程采用自己的局部变量会比全局变量更好,原因前面也介绍了,如果不加锁,多个线程可能会乱改某个全局变量的数值,而局部变量是只有每个线程自己可见,不会影响其他线程。

不过,局部变量的使用也有问题,就是函数调用时候,传递起来会比较麻烦,即如下所示:

def process_student(name):
    std = Student(name)
    # std是局部变量,但是每个函数都要用它,因此必须传进去:
    do_task_1(std)
    do_task_2(std)

def do_task_1(std):
    do_subtask_1(std)
    do_subtask_2(std)

def do_task_2(std):
    do_subtask_2(std)
    do_subtask_2(std)

局部变量需要一层层传递给每个函数,比较麻烦,有没有更好的办法呢?

一个思路是用一个全局的 dict ,然后用每个线程作为 key ,代码例子如下所示:

global_dict = {}

def std_thread(name):
    std = Student(name)
    # 把std放到全局变量global_dict中:
    global_dict[threading.current_thread()] = std
    do_task_1()
    do_task_2()

def do_task_1():
    # 不传入std,而是根据当前线程查找:
    std = global_dict[threading.current_thread()]
    ...

def do_task_2():
    # 任何函数都可以查找出当前线程的std变量:
    std = global_dict[threading.current_thread()]

这种方式理论上是可行的,它可以避免局部变量在每层函数中传递,只是获取局部变量的代码不够优雅,在 threading 模块中提供了 local 函数,可以自动完成这件事情,代码如下所示:

import threading

# 创建全局ThreadLocal对象:
local_school = threading.local()

def process_student():
    # 获取当前线程关联的student:
    std = local_school.student
    print('Hello, %s (in %s)' % (std, threading.current_thread().name))

def process_thread(name):
    # 绑定ThreadLocal的student:
    local_school.student = name
    process_student()

t1 = threading.Thread(target= process_thread, args=('Alice',), name='Thread-A')
t2 = threading.Thread(target= process_thread, args=('Bob',), name='Thread-B')
t1.start()
t2.start()
t1.join()
t2.join()

运行结果:

Hello, Alice (in Thread-A)
Hello, Bob (in Thread-B)

在代码中定义了一个全局变量 local_school ,它是一个 ThreadLocal 对象,每个线程都可以对它读写 student 属性,但又不会互相影响,也不需要管理锁的问题,这是 ThreadLocal 内部会处理。

ThreadLocal 最常用的是为每个线程绑定一个数据库连接,HTTP 请求,用户身份信息等,这样一个线程的所有调用到的处理函数都可以非常方便地访问这些资源。

进程 vs 线程

我们已经分别介绍了多进程和多线程的实现方式,那么究竟应该选择哪种方法来实现并发编程呢,这两者有什么优缺点呢?

通常多任务的实现,我们都是设计 Master-WorkerMaster 负责分配任务,Worker 负责执行任务,因此多任务环境下,通常是一个 Master 和多个 Worker

如果用多进程实现 Master-Worker,主进程就是 Master,其他进程就是 Worker

如果用多线程实现 Master-Worker,主线程就是 Master,其他线程就是 Worker

对于多进程,最大的优点就是稳定性高,因为一个子进程挂了,不会影响主进程和其他子进程。当然主进程挂了,所有进程自然也就挂,但主进程只是负责分配任务,挂掉概率非常低。著名的 Apache 最早就是采用多进程模式。

缺点有:

  • 创建进程代价大,特别是在 windows 系统,开销巨大,而 Unix/ Linux 系统因为可以调用 fork() ,所以开销还行;
  • 操作系统可以同时运行的进程数量有限,会受到内存和 CPU 的限制

对于多线程,通常会快过多进程,但也不会快太多缺点就是稳定性不好,因为所有线程共享进程的内存,一个线程挂断都可能直接造成整个进程崩溃。比如在Windows上,如果一个线程执行的代码出了问题,你经常可以看到这样的提示:“该程序执行了非法操作,即将关闭”,其实往往是某个线程出了问题,但是操作系统会强制结束整个进程。

进程/线程切换

是否采用多任务模式,第一点需要注意的就是,一旦任务数量过多,效率肯定上不去,这主要是切换进程或者线程是有代价的

操作系统在切换进程或者线程时的流程是这样的:

  • 先保存当前执行的现场环境(CPU寄存器状态、内存页等)
  • 然后把新任务的执行环境准备好(恢复上次的寄存器状态,切换内存页等)
  • 开始执行任务

这个切换过程虽然很快,但是也需要耗费时间,如果任务数量有上千个,操作系统可能就忙着切换任务,而没有时间执行任务,这种情况最常见的就是硬盘狂响,点窗口无反应,系统处于假死状态。

计算密集型vsI/O密集型

采用多任务的第二个考虑就是任务的类型,可以将任务分为计算密集型和 I/O 密集型

计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如对视频进行编码解码或者格式转换等等,这种任务全靠 CPU 的运算能力,虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU 执行任务的效率就越低。计算密集型任务由于主要消耗CPU资源,这类任务用 Python这样的脚本语言去执行效率通常很低,最能胜任这类任务的是C语言,我们之前提到了 Python 中有嵌入 C/C++ 代码的机制。不过,如果必须用 Python 来处理,那最佳的就是采用多进程,而且任务数量最好是等同于 CPU 的核心数。

除了计算密集型任务,其他的涉及到网络、存储介质 I/O 的任务都可以视为 I/O 密集型任务,这类任务的特点是 CPU 消耗很少,任务的大部分时间都在等待 I/O 操作完成(因为 I/O 的速度远远低于 CPU 和内存的速度)。对于 I/O 密集型任务,如果启动多任务,就可以减少 I/O 等待时间从而让 CPU 高效率的运转。一般会采用多线程来处理 I/O 密集型任务。

异步 I/O

现代操作系统对 I/O 操作的改进中最为重要的就是支持异步 I/O。如果充分利用操作系统提供的异步 I/O 支持,就可以用单进程单线程模型来执行多任务,这种全新的模型称为事件驱动模型。Nginx 就是支持异步 I/O的 Web 服务器,它在单核 CPU 上采用单进程模型就可以高效地支持多任务。在多核 CPU 上,可以运行多个进程(数量与CPU核心数相同),充分利用多核 CPU。用 Node.js 开发的服务器端程序也使用了这种工作模式,这也是当下实现多任务编程的一种趋势。

在 Python 中,单线程+异步 I/O 的编程模型称为协程,有了协程的支持,就可以基于事件驱动编写高效的多任务程序协程最大的优势就是极高的执行效率,因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销。协程的第二个优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不用加锁,只需要判断状态就好了,所以执行效率比多线程高很多。如果想要充分利用CPU的多核特性,最简单的方法是多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。


参考

  • https://www.liaoxuefeng.com/wiki/1016959663602400/1017627212385376
  • https://github.com/jackfrued/Python-100-Days/blob/master/Day01-15/13.%E8%BF%9B%E7%A8%8B%E5%92%8C%E7%BA%BF%E7%A8%8B.md
  • https://www.runoob.com/python3/python3-multithreading.html

以上就是本次教程的所有内容,代码已经上传到:

https://github.com/ccc013/Python_Notes/blob/master/Tutorials/Process_and_Threading/multi_threading.py

原文发布于微信公众号 - 算法猿的成长(AI_Developer)

原文发表时间:2019-08-28

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券