多线程详解

线程是CPU调度的最小单位,Python由于GIL锁的存在,CPython解释器并不能真正实现多线程,他不适合CPU密集型,但是适合IO密集型,本文主要讲述 python 多线程的使用。



多线程


以下文字出自 菜鸟教程

多线程类似于同时执行多个不同程序,多线程运行有如下优点:

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

每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。【这里的应用程序也可以说是一个进程,进程是操作系统资源分配的最小单位,线程是CPU执行的最小单位】

每个线程都有他自己的一组CPU寄存器,称为线程的上下文,该上下文反映了线程上次运行该线程的CPU寄存器的状态。

指令指针和堆栈指针寄存器是线程上下文中两个最重要的寄存器,线程总是在进程得到上下文中运行的,这些地址都用于标志拥有线程的进程地址空间中的内存。【这段话比较重要,因为线程切换需要随之切换上下文】

  • 线程可以被抢占(中断)。
  • 在其他线程正在运行时,线程可以暂时搁置(也称为睡眠) – 这就是线程的退让。

线程可以分为:

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

Python3 线程中常用的两个模块为:

  • _thread
  • threading(推荐使用)

thread 模块已被废弃。用户可以使用 threading 模块代替。所以,在 Python3 中不能再使用"thread" 模块。为了兼容性,Python3 将 thread 重命名为 “_thread”。

后面讲述的多线程,都是用 threading 模块。

Thread


python中一切皆对象,所以线程也是一个对象。定义在threading库中:

在这里插入图片描述

截图是 Thread 的初始化方法的部分,下面说一下各个参数的含义

  • group: 所属线程组,这个用的少
  • target:一个可执行的对象,一般是一个函数,但是所有定义了__call__ 的类生成的对象都是可执行的对象,里面是我们要用多线程跑的代码
  • name:线程名字,为None时,默认为Thread-N
  • args:线程函数的参数,必须按照顺序传递
  • kwrags:目标函数的关键字参数,传递的是 age=12 这种
  • daemon:线程的模式,其实只有两种值
    • True:当前线程为守护线程,主线程结束了他就结束了,无论执行到哪里了
    • False:主线程结束了,他依然会执行
    • None:从主进程继承

下面用代码来演示 daemon 参数

daemo = True

import threading
import time


def function():
    print('+' * 10, '子线程开始', '+' * 10)
    time.sleep(3)
    print('-' * 10, '子线程结束', '-' * 10)


t = threading.Thread(target=function, daemon=True)
t.start()  # 子线程启动,不调用 start,子线程是不会启动和生效的

time.sleep(1)  # 主线程休眠一秒,可以让子线程被调度执行
print('主线程结束')

在这里插入图片描述

子线程结束并没有打印出来,主线程结束,子线程就结束了。

daemo = False

将 daemo 设置为 False,再执行

在这里插入图片描述

子线程结束打印出来了。

但是这里,很多博客都没讲清楚,如果在主线程里面再添加一行 t.join(),哪怕是子线程被设置为 True,主线程依然会等待子线程执行完。

在这里插入图片描述

join 的作用

join 的作用是让父线程等待子线程执行完,一不留神,可能多线程变成了单线程在使用。这里也是很多博客没有说清楚

在这里插入图片描述

注意观察 join 方法的调用时机,他会让主线程在 join 调用之后就一直处于等待,直到子线程执行完,才会继续向下执行。所以用不好是会变成单线程的。

比如下面这段代码,完全就是单线程的效果

import threading
import time


def func():
    print('*' * 10, f'当前线程 {threading.current_thread().name}')
    time.sleep(5)


for i in range(0, 3):
    t = threading.Thread(target=func, daemon=True)
    t.start()
    t.join()

在循环体内,每次调用了 join(), 必须等到刚刚创建的线程执行完了,主线程才能创建下一个线程

继承使用

上面讲述的方法是直接生成线程对象的方式使用,还可以通过继承 Thread 类实现。

使用threading模块实现一个新的线程,需要下面3步:

  • 继承于Thread 类
  • 重写 __init__ 方法,可以添加额外的参数
  • 最后,需要重写 run() 方法来实现线程要做的事情

run()方法默认就是创建线程threading时,传递的target function。start方法会自动调用run()。

在这里插入图片描述

start 方法已经篇底层了,没必要继续深入研究下去,我们只需要知道,start() 会调用 run(),而 run() 又会调用我们之前传递的 target,所以如果我们直接重写 run 方法,就需要传递 target 了。

使用示例:

import threading
import time


class PrintThread(threading.Thread):

    def __init__(self, xstr, daemon):
        super().__init__(daemon=daemon)
        self.xstr = xstr

    def run(self):
        print(self.xstr)


pt = PrintThread('yaowy', daemon=False)
pt.start()
time.sleep(1)

在这里插入图片描述

模块方法和类方法


模块方法

threading 模块除了包含 _thread 模块中的所有方法外,还提供的其他方法:

  • threading.currentThread(): 返回当前的线程变量。
  • threading.enumerate(): 返回一个包含正在运行的线程的list。正在运行指线程启动后、结束前,不包括启动前和终止后的线程。
  • threading.activeCount(): 返回正在运行的线程数量,与len(threading.enumerate())有相同的结果。
import threading
import time


def func():
    print('*' * 10, f'当前线程 {threading.current_thread().name}')
    time.sleep(5)


for i in range(5):
    threading.Thread(target=func, daemon=False).start()

time.sleep(1)
print(threading.enumerate())
print(f'主线程名{threading.current_thread().name}')
print(threading.activeCount())

在这里插入图片描述

  • 当前活跃的线程是 6,包括主线程
  • activeCount 将要取消,请使用 active_count

类方法

  • run(): 用以表示线程活动的方法。
  • start():启动线程活动。
  • join([time]): 等待至线程中止。这阻塞调用线程直至线程的join() 方法被调用中止-正常退出或者抛出未处理的异常-或者是可选的超时发生。
  • isAlive(): 返回线程是否活动的。
  • getName(): 返回线程名。
  • setName(): 设置线程名。

上面的方法前面都有涉及,这里就不演示了。


多线程多进程经常会问到互斥与同步,但是很多人一直搞不清楚这两个概念。

互斥和同步是两个不一样的概念:

  • 互斥只需要保证同时只有一个线程在处理临界资源。
  • 而所谓同步就是有顺序的执行,拿最经典的生产者-消费者为例,必须要先生产出产品,才能消费。存在先后顺序!

首先要提一个概念:原子操作。无论是lock上锁,还是信号量加减,都必须是原子操作。即该操作要么不执行,一执行就不可中断,直到运行结束。

很好理解,比如 lock 上锁,如果该过程执行到一半,cpu 切换了线程,进而会导致异常。

解决办法:

  • 要么你对lock继续上锁,可这样无穷递归无法解决问题 ×
  • 干脆操作系统规定有些操作不可以中断,就有了原子操作的概念 √

互斥


这里说一下为什么需要互斥。

现在有一个卖票场景,票总数是 100张,线程 AB 都在卖。线程 A 读取到一共有 100 张票,B 也读取到了,A 卖掉了 1 张,将原值改为 99 张, B 也以为有 100 张,卖掉了 一张,也改为 99 张,但其实应该还剩 98张才对。

很遗憾,因为 Python GIL 解释器锁以及当前计算机的超高运算速度的缘故,我试了好久也没办法做出最贴切的案例,这里我做一个类似的案例出来。

# 共享全局变量
import threading
import time

# 资源竞争(未添加锁)示例
global_value = 0


def func():
    global global_value
    b = global_value + 1
    time.sleep(1)  # 这里模拟我们的业务操作
    global_value = b


if __name__ == '__main__':
    threading.Thread(target=func, daemon=False).start()
    threading.Thread(target=func, daemon=False).start()

    time.sleep(3)
    print(global_value)

在这里插入图片描述

假定我们的业务操作比较繁琐耗时,先取全局变量的值,经过一番计算之后,修改全局变量的值,经过两次加 1,他应该等于 2才对,但实际上他等于 1,。这就是资源竞争。

一份资源同一时刻只允许一个线程使用。

Lock

这里就要引入锁机制了,将会读取公共资源,并且可能会修改的代码锁住,某一段时间内只有拿到锁的线程才能执行这段代码。这样就可以避免产生资源竞争。

注意,逻辑上是锁住了公共资源,实际上是锁住了某段代码,使得某一段时间之内,只有一个线程可以执行这段代码。

import threading
import time

global_value = 0  # 公共资源
lock = threading.Lock()  # 锁


def func():
    global global_value
    '''
    这里是各个线程的计算代码,没有读取和操作公共资源
    '''
    lock.acquire()  # 从这里开始,代码被锁住
    b = global_value + 1  # 读取公共资源
    time.sleep(1)  # 这里模拟我们的业务操作
    global_value = b  # 操作公共资源
    lock.release()  # 从这里结束,代码解锁


if __name__ == '__main__':
    threading.Thread(target=func, daemon=False).start()
    threading.Thread(target=func, daemon=False).start()

    time.sleep(5)
    print(global_value)

在这里插入图片描述

这个流程就是:

执行到 lock.acquire 的时候,锁会被当前线程拿到,当另一个线程也执行到这里,尝试拿到锁,结果发现锁未释放,该线程也就不执行了,卡在这里,直到拿到锁的线程执行了 lock.release 释放了锁,该线程抢到了锁,才能执行这段代码。

这里需要注意的是,由于被锁住的代码同一时间段只允许一个线程执行,所以这段代码实际上串行的,但是这段代码之外是并行的,所以锁代码的时候需要尽可能的少锁住,避免串行代码太多,影响了程序的执行效率。线程开的太多,大家都在上锁的代码这里竞争锁资源,也会增加拥堵,影响程序的执行效率。

上锁方法 acquire 和 释放锁方法 release 可以直接用 with 代替

在这里插入图片描述
这两种写法等价。

另外一个 Lock 锁对象只能上一次,不能重复上锁,其他的线程也可以释放该锁。

在这里插入图片描述

【1】加锁,【3】释放了锁,【2】尝试释放锁,结果已经被释放了,因此打印了【4】.释放了的锁不能再次释放。

RLock

递归锁,又名可重入锁。

  • 跟之前 Lock 不同的是,Lock对一个线程只能加一次锁,RLock可以对一个线程加多次锁。
  • 谁拿到谁释放。如果线程A拿到锁,线程B无法释放这个锁,只有A可以释放;
  • acquire多少次就必须release多少次,只有最后一次release才能改变RLock的状态为unlocked

如果一个东西,我们光知道怎么用,而不知道应用场景的话,是很难接受和记住的。因此,我先描述一种场景。

class Num(object):

    def __init__(self):
        self.num = 0

    def op1(self):
        self.num += 1

    def op2(self):
        self.num -= 1
        self.op1()

类 Num 对外提供功能,op1 可以单独给外部使用,op2 也能单独给外部使用,同时 op2 也需要调用 op1,并且资源 num,每次都只能给一个线程使用。这样的应用场景确实存在,但是没办法具现化在博客里。

我们看到了 op1 和 op2 都有操作 num,因此他们操作 num 的时候必须上锁,因为 num 同一时间只允许一个线程使用。

假如说 op1 和 op2 分别使用不同的锁,如果在 op2 获取到了一个锁,想要调用 op2,结果另一个线程,他没有调用 op2, 直接调用 op1,这个时候 op1 中使用的锁还空闲着,被另一个线程拿到了,这个时候就是两个线程都在操作 num,就会发生资源竞争了,不安全。

但是可重入锁完美解决了这个问题。

import threading


class Num(object):

    def __init__(self):
        self.num = 0
        self.rlock = threading.RLock()

    def op1(self):
        with self.rlock:
            self.num += 1

    def op2(self):
        with self.rlock:
            self.num -= 1
            self.op1()

可重入锁的用法和 Lock 一样。

好处: 减少了锁使用数量,定义多个锁增加了维护难度。 保证了代码的扩展性。

RLock 源码

可重入锁的实现非常巧妙,阅读其源码对我们有很大的提升。

在这里插入图片描述

这段代码是我截图拼凑而成,注意看行号

如果_threading中定义了 RLock,则最终会使用 _threading.RLock,否则就是使用 _PyRLock,而 _PyRLock 就是 _RLock。这里使用的是 _RLock,我们就看这个类。

在这里插入图片描述

【1】调用 _allocate_lock 方法创建一个锁,
【2】这里已经是底层了,我们看注释即可。返回一个锁对象
【3】保存锁的拥有者,实例化的时候,他还没有拥有者,为 None
【4】保存一个次数

在这里插入图片描述

某个线程初次上锁

【1】是否阻塞,默认是阻塞的,如果该值为False,表示是非阻塞的,也就是如果获取不到锁,我就立马返回,如果blocking为False,则timeout 参数将会失效,因为既然非阻塞,也就没有必要等待锁释放了。
【2】获取当前线程的ID
【3】第一执行 acquire 方法的时候,_owner 为空,所以这里不会执行
【4】调用保存的所对象的 acquire 方法,如果获取成功,在这里已经开始锁住了,他的返回值是 bool
【5】如果锁住了,就将当前锁对象的拥有者标记为当前线程
【6】如果锁住了,就将上锁次数设置为 1

某个线程多次上锁
【3】此时3必定执行,并且上锁次数增加 1,然后直接返回了。所以多次上锁,底层只上锁了一次。

在这里插入图片描述

【1】如果当前线程不是该锁的拥有者,则弹出运行时错误,保证了只有上锁的线程才能释放锁
【2】释放一次,上锁记录就减1
【3】当上锁次数减到了0,这里就会执行,先将拥有者置为空,再真正的释放锁。

讲同步的时候需要用到信号量,讲信号量又要用到条件,所以下面按照,条件,信号量,同步来讲。其实讲完了条件和信号量之后,同步不讲都没关系。

Condition


Condition 翻译为条件。

所谓 condition 条件变量,即这种机制是在满足了特定的条件后,线程才可以访问相关的数据。 这种同步机制就是一个线程等待特定的条件,另一个线程通知它条件已经发生。一旦条件发生,该线程就会获取锁,从而独占共享资源的访问。

Condition包含以下部分:

  • c.acquire(*args):获取底层锁。此方法将调用底层锁上对应的acquire(*args)方法。
  • c.release():释放底层锁。此方法将调用底层锁上对应的release()方法
  • c.wait(timeout):等待直到获取通知或出现超时为止。此方法在调用线程已经获取锁之后调用。
    调用时,将释放底层锁,而且线程将进入睡眠状态,直到另一个线程在条件变量上执行notify()或notify_all()方法将其唤醒为止。在线程被唤醒后,线程讲重新获取锁,方法也会返回。timeout是浮点数,单位为秒。如果超时,线程将被唤醒,重新获取锁,而控制将被返回。
  • c.notify(n):唤醒一个或多个等待此条件变量的线程。此方法只会在调用线程已经获取锁之后调用,
    而且如果没有正在等待的线程,它就什么也不做。
    n指定要唤醒的线程数量,默认为1.被唤醒的线程在它们重新获取锁之前不会从wait()调用返回。
  • c.notify_all():唤醒所有等待此条件的线程。

一个经典的模型,生产者消费者模型

现在有一个商品队列,只要队列没有满,生产者就应该不断生产商品。只要商品队列还有商品就应该不断消费。如果商品队列空了,消费者就应该休息,如果商品队列满了,生产者就应该休息。

这才是真正的应用场景,然后我翻了不少博客,返现很多案例把多线程写成了单线程,让我疑惑了好久,然后我自己写了一个。

案例

from threading import Thread, Condition
import time

items = []
condition = Condition()


class Consumer(Thread):

    def __init__(self):
        Thread.__init__(self)

    def consume(self):
        global condition
        global items
        if len(items) == 0:  # 不加锁先判断一下,不能随便加锁,否则会影响多线程执行的效率
            with condition:
                if len(items) == 0:  # 二次判断,防止资源竞争判断不对
                    print("Consumer wait : no item to consume")
                    condition.notify() # 先唤醒其他的线程,其实就是通知生产者准备生产了
                    condition.wait() # 然后把自己挂起来,就是休息,如果他被唤醒的话,代码将会从这儿继续执行。
        items.pop()
        print("Consumer notify : consumed 1 item items to consume are " + str(len(items)))

    def run(self):
        for i in range(0, 20):
            time.sleep(2)
            self.consume()


class Producer(Thread):

    def __init__(self):
        Thread.__init__(self)

    def produce(self):
        global condition
        global items
        if len(items) == 10:
            with condition:
                if len(items) == 10:
                    print("Producer wait : stop the production!!")
                    condition.notify()
                    condition.wait()

        items.append(1)
        print("Producer notify : total items producted " + str(len(items)))

    def run(self):
        for i in range(0, 20):
            time.sleep(1)
            self.produce()


if __name__ == "__main__":
    producer = Producer()
    consumer = Consumer()
    producer.start()
    consumer.start()
    producer.join()
    consumer.join()

在这里插入图片描述

只要队列为空,就应该先唤醒生产者,然后自己挂起来,等待被唤醒。其他情况下就应该不断的消费。

在这里插入图片描述

只要商品队列满了,就应该唤醒消费者,自己沉睡。其他情况应该源源不断的生产。

但是上面这个案例有一点点不太好的地方。我们假设生产者生产过快,先填满了队列,然后挂起来了,只有等消费者将队列消费空了,才会将之唤醒,自己挂起来。消费者将之唤醒的时候,自己反而空了,等待生产者填满队列才会继续执行。也就是说,他会渐渐变成了单线程的模式。

改进:

  • 生产者在生产的时候,假定每生产5次,尝试获取一次锁,从而唤醒消费者。在整个程序生产结束的时候再尝试一次,防止漏消费。
  • 消费者在消费的时候,假定每消费5次,尝试获取一次锁,从而唤醒生产者。当消费线程被唤醒的时候,需要判断一次是否为空,如果为空,说明生产者已经不继续消费了。

源码

初始化函数

在这里插入图片描述

这个初始化函数可坑死我了,刚开始没看清楚,wait 方法怎么都读不懂

【1】填充内部锁对象,如果不传递的话,默认是可重入锁;将底层的上锁,释放锁方法绑定到 Condition 上。
【2】覆盖三个方法,如果底层锁对象有这三个方法,则 Condition 的这三个方法与底层的锁对象的同名方法绑定,否则他们指向的就是 Condition 自己的这三个方法,下面 Condition 也是定义了这三个方法的。也不知道作者是怎么考虑的。
【3】保存了休眠的线程,默认是个队列,也就是说先休眠的先被唤醒

wait

在这里插入图片描述

【1】线程判断这个锁是否已经被占用,其实调用的是底层的可重入锁的同名方法,如果不是当前线程拥有该锁,那么就会报错,也就是说,只有使用 Condition 加锁的线程才能执行 await 方法
【2】生成一个锁对象,使用它上锁,并且将他放入等待池中
【3】在这一步,释放了 Condition ,如果是可重入锁,不管重入多少层,统统修改为没有被线程抢占的状态,并且返回了当时重入状态,方便该线程到时候抢占到锁的时候可以恢复到之前的状态。
【4】我们所有的案例都是无 timeout 的,这里使用之前刚刚创建的锁 waiter,再次尝试加锁,注意,这个 waiter 可是普通的锁,而非可重入锁,也就是说在这里,这个线程把自己卡住了。就是在这里完成了休眠,除非其他的线程拿到这个 waiter,释放了他,这个线程才有可能继续走下去。
【5】我们假设当前线程又拿到了 Condition,就是在这里恢复可重入锁的上锁数据的。
【6】将之前创建的 waiter 移除。

notify

在这里插入图片描述

【1】判断先前线程是否为 Condition 的拥有者
【2】循环取出队列中用来阻塞的 waiter
【3】将 waiter 释放,释放之后,之前用 waiter 卡住自己的线程就可以继续执行下去了,这就是所谓的唤醒。
【4】这里尝试移除这个 waiter。

总结

Condition 通过创建一个临时的 waiter,然后两次上锁,卡主自己,实现线程休眠。一直到别的线程释放了这个锁,这个线程才能继续走下去,这就是所谓的唤醒。

Semaphore


信号量

Lock 锁保证了同时只有一个人可以访问特定的资源,但是 Semaphore 信号量保证了同一时间最多有多少个线程访问特定的资源。

他是怎么实现的?看源码

acquire
在这里插入图片描述
【1】注意,这里传递的是 Lock,也就是说,信号量是不支持可重入锁,使用的是原生的 Lock
【2】保存了传递一个数值
【3】这里是尝试获取一个锁,假如获取不到锁就会卡住,如果是非阻塞的,获取不到就直接返回了 False。
【4】假如说当前线程获取到了锁,就会进入这个循环。如果说 value 等于 0,当前线程就会进入循环等待,为什么使用循环等待呢?这里是怕别的线程不小心激活了当前线程,但是因为 value 依然为 0,也就是说允许访问的线程数量已经满了,还是需要等待。假如说 value 大于 0 ,那么就将 value 减小 1,同时返回 True,这个时候表示允许访问的线程数量增加了一个,剩余的数量减小了一个。

release

在这里插入图片描述

尝试唤醒当前沉睡的线程。

总结

Semaphore 信号量只是对 Condition 条件的一种运用而已。

总结

Lock,RLock,Condition,Semaphore 估计小伙伴们都已经有点蒙蔽了,实际上,这四个东西有点费脑子,需要多次使用,仔细感悟代码的精妙,这里对他们之间的关系做个总结和梳理。大家带着这些梳理,可以更高的吃透上面的源码,以及他们的用法。

锁是为了解决资源竞争。

  • Lock,原生锁,最底层的锁,他可以卡住代码,被他卡住的代码,同一时间只有一个线程可以执行。
  • RLock,可重入锁,对 Lock 进行了封装,实现了可重入,本质还是 Lock。
  • Condition,条件,可以当锁来用,但他远不是锁这么简单,他利用锁实现了线程的休眠和唤醒。 线程在哪里卡住的,在 wait 方法内部卡住的。
  • Semaphore,信号量,他不是锁,不能当锁用,他利用 Condition 实现了资源的竞争程度,同一时刻限定操作资源的线程数。底层是用的是 原生锁,而非可重入锁。他用原生锁锁的是自己的代码,从而控制并发量。
  • 20
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值