Python3 多线程编程

一、线程的基本概念

引入进程的目的,是为了使多道程序并发执行,以提高资源利用率和系统吞吐量;而引入线程,则是为了减小程序在并发执行时所付出的时空开销,提高操作系统的并发性能。

线程最直接的理解就是“轻量级进程”,它是一个基本的CPU执行单元,也是程序执行流的最小单元,由线程ID、程序计数器、寄存器集合和堆栈组成。线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可与同属一个进程的其他线程共享进程所拥有的全部资源。一个线程可以创建和撤销另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。线程也有就绪、阻塞和运行三种基本状态。

引入线程后,进程的内涵发生了改变,进程只作为除CPU以外系统资源的分配单元,线程则作为处理机的分配单元。

二、线程与进程的比较

  1. 调度。在传统的操作系统中,拥有资源和独立调度的基本单位都是进程。在引入线程的操作系统中,线程是独立调度的基本单位,进程是资源拥有的基本单位。在同一进程中,线程的切换不会引起进程切换。在不同进程中进行线程切换,如从一个进程内的线程切换到另一个进程中的线程时,会引起进程切换。

  2. 拥有资源。不论是传统操作系统还是设有线程的操作系统,进程都是拥有资源的基本单位,而线程不拥有系统资源(也有一点必不可少的资源),但线程可以访问其隶属进程的系统资源。

  3. 并发性。在引入线程的操作系统中,不仅进程之间可以并发执行,而且多个线程之间也可以并发执行,从而使操作系统具有更好的并发性,提高了系统的吞吐量。

  4. 系统开销。由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、 I/O设备等,因此操作系统所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程CPU环境的保存及新调度到进程CPU环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。此外,由于同一进程内的多个线程共享进程的地址空间,因此,这些线程之间的同步与通信非常容易实现,甚至无需操作系统的干预。

  5. 地址空间和其他资源(如打开的文件):进程的地址空间之间互相独立,同一进程的各线程间共享进程的资源,某进程内的线程对于其他进程不可见。

  6. 通信方面:进程间通信(IPC)需要进程同步和互斥手段的辅助,以保证数据的一致性,而线程间可以直接读/写进程数据段(如全局变量)来进行通信。

三、线程的属性

在多线程操作系统中,把线程作为独立运行(或调度)的基本单位,此时的进程,已不再是一个基本的可执行实体。但进程仍具有与执行相关的状态,所谓进程处于“执行”状态,实际上是指该进程中某线程正在执行。线程的主要属性如下:

线程是一个轻型实体,它不拥有系统资源,但每个线程都应有一个唯一的标识符和一个线程控制块,线程控制块记录了线程执行的寄存器和栈等现场状态。

不同的线程可以执行相同的程序,即同一个服务程序被不同的用户调用时,操作系统为它们创建成不同的线程。

同一进程中的各个线程共享该进程所拥有的资源。

线程是处理机的独立调度单位,多个线程是可以并发执行的。在单CPU的计算机系统中,各线程可交替地占用CPU;在多CPU的计算机系统中,各线程可同时占用不同的CPU,若各个CPU同时为一个进程内的各线程服务则可缩短进程的处理时间。

—个线程被创建后便开始了它的生命周期,直至终止,线程在生命周期内会经历阻塞态、就绪态和运行态等各种状态变化。

四、多线程编程

Python 中使用多线程用的是标准库提供了两个模块:_threadthreading

threading 是高级模块,对 _thread 进行了封装,所以我们在开发多线程的时候都在使用 threading 这个高级模块

代码示例:

# !/usr/bin/env python3
# -*- coding: UTF-8 -*-

from threading import Thread
from threading import current_thread


def func(x, y):
    print('Func 线程名称:', current_thread().name)
    print(x + y)


print('Main 线程名称:', current_thread().name)

t1 = Thread(target=func, args=(1, 2))
t2 = Thread(target=func, args=(3, 4))
t3 = Thread(target=func, name='FuncThread', args=(5, 6))

t1.start()
t2.start()
t3.start()

t1.join()
t2.join()
t3.join()

执行结果:

Main 线程名称: MainThread
Func 线程名称: Thread-1 
3
Func 线程名称: Thread-2 
Func 线程名称: FuncThread
7
11

代码解读:

和多进程类似,多线程也需要一个执行函数,参数也类似,target 函数名,args 参数。

因为 CPU 的最小调度单位就是线程(进程作是资源分配的最小单位)。任何进程都会有一个线程在干活,我们把该线程称为主线程,主线程又可以启动新的线程称之为子线程。

Python 的 threading 模块有个 current_thread() 函数,它返回当前线程的实例。主线程实例的名字叫 MainThread,如果子线程的名字未指定,那么它默认名字是自动以 Thread-1、Thread-2 。。。 这样的名字命名的。

如果我们在创建线程的时候指定名称 name='FuncThread' 那么它的名字就是你指定的名字。

start() 开始执行线程

join() 等待线程执行完成

五、线程锁 Lock

上面我们对进程和线程做了区分,知道了线程是共享数据的。那如果多个线程同时操作一个变量,把数据改乱了怎么办?

我们先来看一下该乱的情况

代码示例:

# !/usr/bin/env python3
# -*- coding: UTF-8 -*-

from threading import Thread


counter = 0


def handle(n):
    global counter
    counter = counter + n
    counter = counter - n


def run(n):
    for i in range(10**6):
        handle(n)


t1 = Thread(target=run, args=(3,))
t2 = Thread(target=run, args=(6,))
t3 = Thread(target=run, args=(9,))

t1.start()
t2.start()
t3.start()

t1.join()
t2.join()
t3.join()

print(counter)

执行结果:

27

代码解析:

我们定义了一个全局计数器变量 counter,初始值为0,并且启动 3 个线程,先加后减,理论上结果应该为0。

但是,由于线程的调度是由操作系统决定的,当 t1、t2、t3 交替执行时,只要循环次数足够多,counter 的结果就不一定是0了。

因为是并发执行的,等第一个线程还没把数据回写到 counter 中,另一个线程就把里面的值改变了,这就是多线程使用的不方便之处。

于是,我们在使用多线程的时候就需要给执行函数加一把锁,一把锁对应一把钥匙,然后让线程们开抢,谁先抢到谁先操作。其他的线程怎么办?不好意思,接着抢,啥时候抢到啥时候算。。。

有了这样的机制,就不怕多线程之间并发执行而导致数据安全了,因为同一时刻最多只有一个线程持有该锁。

创建一个锁就是通过 Lock 来实现

代码示例:

# !/usr/bin/env python3
# -*- coding: UTF-8 -*-

from threading import Thread, Lock


counter = 0
lock = Lock()


def handle(n):
    global counter
    counter = counter + n
    counter = counter - n


def run(n):
    for i in range(10**6):
        lock.acquire()      # 加锁
        handle(n)
        lock.release()      # 解锁


t1 = Thread(target=run, args=(3,))
t2 = Thread(target=run, args=(6,))
t3 = Thread(target=run, args=(9,))

t1.start()
t2.start()
t3.start()

t1.join()
t2.join()
t3.join()

print(counter)

执行结果:

0

代码解读:

lock = Lock() 创建一个线程锁(也可以叫做:互斥锁)

lock.acquire() 在使用之前先获取该锁,别的线程只能痴痴等待,啥也干不了

lock.release() 在执行完成一定要切记释放锁,否则那些苦逼等待锁的线程将永远等待下去,成为死线程

锁的好处就是确保了某段关键代码只能由一个线程从头到尾完整地执行,坏处当然也很多,首先是阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了。其次,由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁,导致多个线程全部挂起,既不能执行,也无法结束,只能靠操作系统强制终止。

六、GIL

GIL [Global Interperter Lock] 全局解释器锁简称

首先需要明确一点就是,GIL 并不是 Python 语言的特性,它是在现实 Python 解释器时引用的一个概念。GIL 只在 CPython 解释器上存在。作用是保证同一时间内只有一个线程在执行。

在 Python 中,无论你启多少个线程,你有多少个 cpu, Python 在执行的时候会淡定的在同一时刻只允许一个线程运行。

线程互斥锁和GIL的区别

  1. 线程锁是代码层面的锁,解决 Python 程序中多线程共享资源的问题(线程数据共共享,当各个线程访问数据资源时会出现竞争状态,造成数据混乱)

  2. GIL 是解释层面的锁,解决解释器中多个线程的竞争资源问题(多个子线程在系统资源竞争是,都在等待对象某个部分资源解除占用状态,结果谁也不愿意先解锁,然后互相等着,程序无法执行下去)。

GIL 存在的意义

因为 Python 的线程是调用操作系统的原生线程,这个原生线程就是 C 语言写的原生线程。因为 Python 是用 C 写的,启动的时候就是调用的 C 语言的接口。因为启动的 C 语言的远程线程,那它要调这个线程去执行任务就必须知道上下文,所以 Python 要去调 C 语言的接口的线程,必须要把这个上限问关系传给 Python,那就变成了一个我在加减的时候要让程序串行才能一次计算。就是先让线程 1,再让线程 2。。。

每个线程在执行的过程中,Python 解释器是控制不了的,因为是调的 C 语言的接口,超出了 Python 的控制范围,Python 的控制范围是只在 Python 解释器这一层,所以 Python 控制不了 C 接口,它只能等结果。所以它不能控制让哪个线程先执行,因为是一块调用的,只要一执行,就是等结果,这个时候4个线程独自执行,所以结果就不一定正确了。
 
有了 GIL,就可以在同一时间只有一个线程能够工作。虽然这 4 个线程都启动了,但是同一时间我只能让一个线程拿到这个数据。其他的几个都干等。Python 启动的 4 个线程确确实实落到了这 4 个 cpu 上,但是为了避免出错。

其实线程锁完全可以替代 GIL,但是 Python 的后续功能模块都是加在 GIL 基础上的,所以无法更改或去掉 GIL。这个是 Python 的一个开发时候,设计的一个缺陷,是 Python 语言最大的 BUG。

如何避免 GIL 带来的影响

就目前情况来看,只能用 多进程协程 改善,或者使用其他语言写的 Python 解释器,如:Jython,当然了,你也可以自己写一个 Python 的解释器。

七、线程池

对于任务数量不断增加的程序,每有一个任务就生成一个线程,最终会导致线程数量的失控。作为新任务,这个时候,就要为这些新的链接生成新的线程,线程数量暴涨。在之后的运行中,线程数量还会不停的增加,完全无法控制。所以,对于任务数量不端增加的程序,固定线程数量的线程池是必要的。

如何实现线程池

  1. threadpool 模块

  2. concurrent.futures 模块

  3. 自己构建线程池

由于 threadpool 是一个很老的模块了,所以不推荐使用。有些时候由于业务的特殊性需要自己构建线程池,并不适用于大众,所以现在只介绍一种方式:concurrent.futures 模块

concurrent.futures 是 Python3 的内置模块,我们可以直接使用

代码示例:

#! /usr/bin/env python
# -*- coding: utf-8 -*-

from concurrent.futures import ThreadPoolExecutor


def main(x):
    return x * x


thread = ThreadPoolExecutor(4)

# Submit
datas = []
for i in range(10):
    data = thread.submit(main, i)
    datas.append(data)

for i in datas:
    print(i.result())


# Map
data = thread.map(main, range(10))
print(list(data))

执行结果:

0
1
4
9
16
25
36
49
64
81
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

代码解读:

ThreadPoolExecutor 构造实例的时候,传入 max_workers 参数来设置线程池中最多能同时运行的线程数目,也可以省略参数,创建一个具有 4 线程的线程池 thread = ThreadPoolExecutor(4) 

在提交任务的时候,有两种方式,一种是 submit,另一种是 map,两者的主要区别在于:map可以保证输出的顺序, submit输出的顺序是乱的

submit() 函数来提交线程需要执行的任务(函数名和参数)到线程池中,并返回该任务的句柄(类似于文件、画图),submit 不是阻塞的,而是立即返回

map() 函数只需要提交一次目标函数,目标函数的参数放在一个迭代器(列表,字典)里就可以,map 是阻塞的

done() 方法判断该任务是否结束

cancel() 方法可以取消提交的任务,如果任务已经在线程池中运行了,就取消不了

result() 可以获取任务的返回值,这个方法是阻塞的

你可能感兴趣的:(#,Python3,基础知识)