python——线程同步和线程安全

劝君惜取少年时

线程安全

系统的线程调度具有一定的随机性,当使用多个线程来访问同一个数据时,很容易“偶然”出现线程安全问题。
线程安全问题实际上是给数据造成了混乱,产生了问题。
以下为一个经典的"银行取钱" 的线程安全问题:

import threading
import time


class Account:
    # 定义构造器
    def __init__(self, account_no, balance):
        # 封装账户编号、账户余额的两个成员变量
        self.account_no = account_no
        self.balance = balance


# 定义一个函数来模拟取钱操作
def draw(account, draw_amount):
    # 账户余额大于取钱数目
    if account.balance >= draw_amount:
        # 吐出钞票
        print(">>>{}取钱成功!吐出钞票:{}".format(threading.current_thread().name, draw_amount))
        
        # 人为阻塞,制造线程切换
        # time.sleep(0.001)
        
        # 修改余额
        account.balance -= draw_amount
        print(">>>余额为:{}".format(account.balance))
    else:
        print(">>>{}取钱失败!余额不足!".format(threading.current_thread().name))


# 创建一个账户
acct = Account("1234567", 1000)
# 模拟两个线程对同一个账户取钱
t1 = threading.Thread(name='甲', target=draw, args=(acct, 800)).start()
t2 = threading.Thread(name='乙', target=draw, args=(acct, 800)).start()

以上代码,运行后,有概率遇到以下结果:

>>>甲取钱成功!吐出钞票:800
>>>>乙取钱成功!吐出钞票:800
>>>余额为:200
>>>>余额为:-600

显然,这个结果并不是我们想要的,因为线程调度的不确定性,多线程编程突然出现的“偶然” 错误。

线程安全问题的原因

系统在运行这两个线程时,是交替执行的(并发),在一段时间内(时间片)执行甲线程,一段时间内(时间片)执行乙线程,而取钱扣减操作: account.balance -= draw_amount,并不是原子性的操作(原子性操作,个人理解为要么不执行,要么全部执行),我们可以将 account.balance -= draw_amount 理解为先执行 account.balance - draw_amount操作,再执行赋值(=)操作,两个步骤,在时间片切换中,可能会在某个时刻,将某个线程的两个动作分开,导致最终无法获得预期的结果。

当然,我们可以人为的使用 time.sleep(0.001) 来强制线程调度切换,但也无法完全的避免这种操作,只可能降低概率,但是这种程序上,我们无法赌概率,出现一次,就是失败。

解决线程安全问题:
account.balance -= draw_amount变为“原子性”操作,要么不执行,要么全部执行,如何变为原子性?那么将引入锁。

Lock

为了解决这个问题,Python 的 threading 模块引入了互斥锁(Lock)。
Lock 是控制多个线程对共享资源进行访问的工具。凡是存在共享资源争抢的地方都可以使用锁,从而保证只有一个使用者可以完全使用这个资源。

通常,锁提供了对共享资源的独占访问,每次只能有一个线程对 Lock 对象加锁,一旦线程获得锁,其他试图获得锁的线程将被阻塞。当获得锁的线程对共享资源访问完成后,程序释放对 Lock 对象的锁定。

整个过程可以理解为:拿到锁的线程,也就是正常调用了Lock对象acquire方法的线程,可以执行共享资源的修改,其他试图加锁,也就是试图调用Lock对象acquire方法的线程都会被阻塞等待加锁,直到别的线程释放锁。

锁并不是锁资源,而是锁“操作”,锁是为了让某些操作(代码片段)变得具有“原子性”。

Lock类提供了如下两个方法来加锁和释放锁:

acquire方法

acquire(blocking=True, timeout=-1):请求对 Lock对象加锁,默认阻塞,阻塞可以设置超时时间。非阻塞时,timeout禁止设置。成功获取锁,返回True,否则返回False

release方法

release():释放锁。释放锁,可以在任何线程调用释放,已上锁的锁,会被重置为unlocked,对未上锁的锁调用,会抛出异常。

在编写代码的过程中,要遵循“加锁→修改→释放锁”的安全访问逻辑。所以一般我们需要四步走:

  • 1.实例化Lock对象,注意不要多次实例化,多个线程针对同一个共享资源访问的锁一定要是一个
  • 2.加锁,调用Lock对象的acquire方法
  • 3.执行共享资源的修改
  • 4.释放锁,调用Lock的release方法

银行取钱场景加锁

以下是对"银行取钱"代码进行简单的加锁

import threading
import time


class Account:
    # 定义构造器
    def __init__(self, account_no, balance):
        # 封装账户编号、账户余额的两个成员变量
        self.account_no = account_no
        self.balance = balance


# 创建Lock对象
lock = threading.Lock()


# 定义一个函数来模拟取钱操作
def safe_draw(account, draw_amount):
    # 加锁
    lock.acquire()
    # 资源修改——————————————————————————————
    # 账户余额大于取钱数目
    if account.balance >= draw_amount:
        # 吐出钞票
        print(">>>{}取钱成功!吐出钞票:{}".format(threading.current_thread().name, draw_amount))

        # 人为阻塞,制造线程切换
        # time.sleep(0.001)
        # 修改余额
        account.balance -= draw_amount
        print(">>>余额为:{}".format(account.balance))
    else:
        print(">>>{}取钱失败!余额不足!".format(threading.current_thread().name))
    # 资源修改——————————————————————————————

    # 释放锁
    lock.release()


# 创建一个账户
acct = Account("1234567", 1000)
# 模拟两个线程对同一个账户取钱
t1 = threading.Thread(name='甲', target=safe_draw, args=(acct, 800)).start()
t2 = threading.Thread(name='乙', target=safe_draw, args=(acct, 800)).start()

print异常现象

此外,在使用多线程的过程中,我们发现一些print操作,经常发生没有换行,部分内容出现在同一行的现象。
如以下代码:

import threading


def sub_thread3():
    for i in range(10):
        print('this is sub_thread3')


def sub_thread4():
    for i in range(10):
        print('this is sub_thread4')


t3 = threading.Thread(target=sub_thread3)
t4 = threading.Thread(target=sub_thread4)
t3.start()
t4.start()

结果:
在这里插入图片描述

是因为Python中的print()并不是“原子性”操作,因为print()函数分为两步:

  • 文本内容
  • 末尾的换行符

那现在为了让打印的内容更规范,可以利用锁赋予print函数”原子性“的特性,这样就不会出现print的异常现象了

import threading

lock = threading.Lock()


def sub_thread3():
    for i in range(10):
        lock.acquire()
        print('this is sub_thread3')  # 锁将print函数变得具有“原子性”
        lock.release()


def sub_thread4():
    for i in range(10):
        lock.acquire()
        print('this is sub_thread4')  # 锁将print函数变得具有“原子性”
        lock.release()


t3 = threading.Thread(target=sub_thread3)
t4 = threading.Thread(target=sub_thread4)
t3.start()
t4.start()

with语法

我们可以使用with语法对获取锁和释放锁进行简写,当然threading模块中所有带有acquire()和release()方法的对象,都可以使用with语句。当进入with语句块时,acquire()方法被自动调用,当离开with语句块时,release()语句块被自动调用。包括Lock、RLock、Condition、Semaphore。

with lock对象:
    # do something
    pass

等价于:

lock对象.acquire()
try:
	# do something
	pass
finally:
	lock对象.release()

非阻塞锁

如果想让多个线程同时都可以使用一个锁对象,可以使用非阻塞锁,在调用acquire方法时传入False即可。这样使用同一个Lock锁对象时,第二个线程仍可以使用锁,且第一个锁不会被阻塞。

lock = threading.Lock()
lock.acquire(False)

案例:

import threading,time
 
lock = threading.Lock()
 
def foo():
    ret = lock.acquire(False)  # 非阻塞锁
    print("{} Locked. {}".format(ret,threading.current_thread()))
    time.sleep(10)
 
threading.Thread(target=foo).start()
threading.Thread(target=foo).start()

结果:

True Locked. <Thread(Thread-1, started 6185676800)>
False Locked. <Thread(Thread-2, started 6202503168)>

使用锁的优势

通过使用 Lock 对象可以非常方便地实现线程安全的类,线程安全的类具有如下特征:

  • 该类的对象可以被多个线程安全地访问。
  • 每个线程在调用该对象的任意方法之后,都将得到正确的结果。
  • 每个线程在调用该对象的任意方法之后,该对象都依然保持合理的状态。

Lock是一把双刃剑,虽然确保了代码的完整运行和资源正确,但也会阻止线程并发,降低代码的运行效率,所以在使用时要根据代码的特点合理正确的使用该方法。

RLock

可重入锁RLock 也是threading标准库提供的锁类,也同样拥有acquire和release方法。
在应用锁对象时,会发生死锁;死锁是指两个或两个以上的线程在执行过程中,因争夺资源访问权而造成的互相等待的现象,从而一直僵持下去。
可重入锁就是用来解决死锁问题的,RLock其实底层维护了一个互斥锁Lock和一个计数器counter变量,counter记录着acquire的次数,从而使得资源可以被多次acquire,直到一个线程的所有acquire都被release时,其他线程才能够acquire资源。

import threading
lock = threading.RLock()

if lock.acquire():
    for i in range(10):
        print('获取第二把锁')
        lock.acquire()
        print(f'test.......{i}')
        lock.release()
    lock.release()

以上代码,使用的是RLock,不会阻塞,因为RLock有可重入性,已经获得锁的那一个线程可以再进行多次的acquire。如果是Lock,就会阻塞。

Lock 和 RLock 的区别如下:
threading.Lock:它是一个基本的锁对象,每次只能锁定一次,其余的锁请求,需等待锁释放后才能获取。
threading.RLock:它代表可重入锁(Reentrant Lock)。对于可重入锁,在同一个线程中可以对它进行多次锁定,也可以多次释放。如果使用 RLock,那么 acquire() 和 release() 方法必须成对出现。如果调用了 n 次 acquire() 加锁,则必须调用 n 次 release() 才能释放锁。

由此可见,RLock 锁具有可重入性。也就是说,同一个线程可以对已被加锁的 RLock 锁再次加锁,RLock 对象会维持一个计数器来追踪 acquire() 方法的嵌套调用,线程在每次调用 acquire() 加锁后,都必须显式调用 release() 方法来释放锁。所以,一段被锁保护的方法可以调用另一个被相同锁保护的方法。

RLock原理

当一个线程通过acquire()获取一个锁时,首先会判断拥有锁的线程和调用acquire()的线程是否是同一个线程,如果是同一个线程,那么计数器+1,函数就直接返回(return 1),如果两个线程不一致时,那么会通过调用底层锁(_allocate_lock())进行阻塞自己(也可能是获得锁)。

Local类

由于多线程共享全局变量,所以一个线程拿到的全局变量的值未必是当时自己修改后的值,有可能在拿到全局变量之前其它线程也对该全局变量进行了修改,原因是线程的调度是由操作系统决定的,如下:

from threading import Thread, get_ident
import time
 
num = 0
 
 
def task(arg):
    # get_ident()返回的就是每一个线程的唯一标识
    print(get_ident())
    global num
    num += 1
    time.sleep(1)
    print(num)
 
 
for i in range(10):
    t = Thread(target=task, args=(i,))
    t.start()

由于每个线程只对全局变量做了一次修改,我们中间设置了1秒的延时,所以10个线程打印的全局变量的值都是最后修改的值,而并不是当时线程自己修改后的值。

使用 threading 模块中的 local() ,可以为各个线程创建完全属于它们自己的变量(又称线程局部变量)。正是由于各个线程操作的是属于自己的变量,该资源属于各个线程的私有资源,因此可以从根本上杜绝发生数据同步问题

import threading
import time

num = 0

local_obj = threading.local()


def task(arg):
    """
    threading.local的作用:为每个线程开辟一块空间进行数据的存储
    空间与空间之间数据是隔离的
    """
    # get_ident()返回的就是每一个线程的唯一标识
    print(threading.get_ident())
    # 线程执行到此的时候,为每一个线程开辟一块空间用来存储对象的值
    local_obj.value = arg
    time.sleep(1)
    print(local_obj.value)


for i in range(10):
    t = threading.Thread(target=task, args=(i,))
    t.start()

当为对象赋值属性的时候,会为每个线程开辟一块空间来存储对象的属性值,空间与空间之间数据是隔离的,这样最终打印的时候就是线程自己空间中保存的数据的值。

线程同步

我们使用多线程的目的通常是并发的运行单独的操作,但有时候也需要在两个或多个线程中同步操作,比如一个线程需要通过判断另一个线程的状态来确定自己下一步的操作。
在Python中,线程同步有多种方式,包括Event、Condition和Barrier,都由threading标准库提供。

Event类

如果一个或多个线程需要知道另一个线程的某个状态才能进入下一步的操作,可以使用线程的event事件对象来处理。
Event 对象包含一个可由线程设置的信号标志,它允许线程等待某些事件的发生。
如果有线程等待一个Event对象, 而这个Event对象的标志为假,那么这个线程将会被一直阻塞直至该标志为真。
如果一个线程将一个Event对象的信号标志设置为真,它将唤醒所有等待这个Event对象的线程。
如果一个线程等待一个已经被设置为真的Event对象,那么它将忽略这个事件, 继续执行。

Event对象的几种方法:

set()

event.set(): 设置event的状态值为True,所有阻塞池的线程激活进入就绪状态, 等待操作系统调度;

wait()

event.wait():如果 event.isSet()==False将阻塞线程,默认就是False的,所如果没有其他线程调用set(),默认就是阻塞的;

clear()

event.clear():恢复event的状态值为False。

isSet()

event.isSet():返回event的状态值;

举例:

import threading
import time

# 实例化Event
event = threading.Event()


def car(name):
    print('车:{}正在等待绿灯亮起'.format(name))
    event.wait()  # 等待事件为True,可以理解为等待绿灯亮
    print('车:{}开始通行'.format(name))


def light():
    print('red is lighting')
    time.sleep(3)
    event.set()  # 设置事件为True,可以理解为设置绿灯亮


if __name__ == '__main__':

    # 创建线程控制红绿灯
    thread_light = threading.Thread(target=light)
    thread_light.start()

    # 创建车线程
    for i in range(5):
        thread_car = threading.Thread(target=car, args=('Thread-car-{}'.format(i),))
        thread_car.start()

结果:

red is lighting
车:Thread-car-0正在等待绿灯亮起
车:Thread-car-1正在等待绿灯亮起
车:Thread-car-2正在等待绿灯亮起
车:Thread-car-3正在等待绿灯亮起
车:Thread-car-4正在等待绿灯亮起
车:Thread-car-0开始通行
车:Thread-car-2开始通行
车:Thread-car-4开始通行
车:Thread-car-3开始通行
车:Thread-car-1开始通行

以上案例可以看到:
1、车线程遇到event.wait()进入阻塞状态,等待事件状态值为True,可以理解为等待绿灯亮。
2、灯线程在等待3秒后,执行event.set() ,即:设置事件状态值为True,可以理解为设置绿灯亮。
3、车线程等到事件状态值为True后,继续执行后续代码。

Condition类

互斥锁Lock和RLock只能提供简单的加锁和释放锁等功能,它们的主要作用是在多线程访问共享数据时,保护共享数据,防止数据被脏读脏写,保证数据和关键代码的完整性。
在此基础上,Python提供了Condition类,Condition类不仅自身依赖于Lock和RLock,即具有它们的阻塞特性,此外还提供了一些有利于线程通信,以及解决复杂线程同步问题的方法,它也被称作条件变量。

可以认为,除了Lock带有的锁定池外,Condition还包含一个等待池,池中的线程处于状态图中的等待阻塞状态,直到另一个线程调用notify()/notifyAll()通知;得到通知后线程进入锁定池等待锁定。

自我感觉Condition是锁 和 事件Event 的加强版。

构造方法

threading.Condition(lock=None),可以传入一个Lock或RLock对象,默认是RLock。
从Condition类的构造方法可以看出,Condition类总是与一个锁相关联。

  • 在创建一个Condition类的同时就应该传入Condition类需要绑定的Lock对象;
  • 如果不指定lock参数,那么Python会自动创建一个与之绑定的RLock对象;

acquire()

调用与Condition实例关联的Lock/RLock的acquire()方法。

release()

调用Condition类关联的Lock/RLock的acquire()方法。

wait(timeout)

1)线程挂起,释放Lock锁,直到收到一个notify通知或者等待时间超出timeout,线程才会被唤醒,自动尝试加锁;
2)注意:wait()必须在已获得Lock前提下才能调用,即:调用过acquire()方法后。否则会引起RuntimeError错误。

wait_for(predicate, timeout=None)

与wait方法相似,等待,直到条件计算为True,返回最后一次的predicate的返回值。predicate参数为一个返回值为布尔值的可调用对象。调用此方法的时候会先调用predicate对象,如果返回的就是True,则不会释放锁,直接往后执行。另一个线程通知后,在它释放锁时,才会触发wait_for方法等待事件,这时如果predicate结果为True,则尝试获取锁,获取成功后则继续往后执行,如果为False,则会一直阻塞下去。此方法如果忽略timeout参数,就相当于:while not predicate(): condition_lock.wait()。

notify(n=1)

1)唤醒在Condition的waiting池中的n(参数n可设置,默认为1)个正在等待的线程并通知它,收到通知的线程将自动尝试加锁,继续进行;
2)如果waiting池中有多个线程,随机选择n个唤醒;
3)notify()必须在已获得Lock前提下才能调用,即:调用过acquire()方法后。否则会引起RuntimeError错误。
4)notify()不会主动释放Lock,所以一般代码最后需要手动release释放Lock。

notify_all()

1)唤醒waiting池中的等待的所有线程并通知它们,收到通知的所有线程将自动尝试加锁,继续进行;

案例举例:

import threading


class Girl(threading.Thread):
    def __init__(self, condition, name):
        super().__init__()
        self.name = name
        self.condition = condition

    def run(self):
        # 1.获取锁
        self.condition.acquire()

        # 2.等待李磊的求婚,,阻塞,然后释放锁
        self.condition.wait()

        # 7.接收到李磊的notify,继续执行
        print(self.name + ': 没有情调,不够浪漫,不答应')

        # 8.通知李磊没有情调,不够浪漫,不答应
        self.condition.notify()

        # 9.等待李磊的进一步行动,阻塞,然后释放锁
        self.condition.wait()

        # 13.接收到李磊的notify,继续执行
        print(self.name + ': 好的,答应你了')

        # 14.通知李磊好的,答应你了
        self.condition.notify()

        # 15.自行释放锁
        self.condition.release()


class Boy(threading.Thread):
    def __init__(self, condition, name):
        super().__init__()
        self.condition = condition
        self.name = name

    def run(self):
        # 3.男孩获取到锁
        self.condition.acquire()

        # 4.让女孩嫁给她把
        print(self.name + ': 嫁给我吧!')

        # 5.唤醒女孩挂起的线程,让韩梅梅表态(唤醒的是Girl类中wait()的线程)
        self.condition.notify()

        # 6.释放内部占用的锁,同时线程被挂起,直至接收到通知被唤醒或者超时,等待韩梅梅回答
        self.condition.wait()

        # 10.膝跪下,送上戒指!!
        print(self.name + ': 单膝跪下,送上戒指!!')

        # 11.唤醒女孩挂起的线程,让韩梅梅进一步表态(唤醒的是Girl类中wait()的线程)
        self.condition.notify()

        # 12.释放内部占用的锁,同时线程被挂起,直至接收到通知被唤醒或者超时,等待韩梅梅回答
        self.condition.wait()

        # 16.太太,你的选择太明智了
        print(self.name + ': 太太,你的选择太明智了')

        # 自行释放锁
        self.condition.release()


cond = threading.Condition()

girl = Girl(cond, "韩梅梅")
girl.start()
boy = Boy(cond, "李磊")
boy.start()

结果:

李磊: 嫁给我吧!
韩梅梅: 没有情调,不够浪漫,不答应
李磊: 单膝跪下,送上戒指!!
韩梅梅: 好的,答应你了
李磊: 太太,你的选择太明智了

执行步骤已在注释中标明顺序。

生产者消费者问题

问题:

假设有一群生产者(Producer)和一群消费者(Consumer)来生产寿司和吃寿司,起初寿司为5
生产者的”策略“是如果寿司数量为0,则生产5个寿司;
消费者的”策略“是如果寿司数量大于0,则吃一个寿司;
import threading
import time


# 生产者
class Producer(threading.Thread):
    def __init__(self, name):
        super(Producer, self).__init__()
        self.name = name

    def run(self):
        while True:
            con.acquire()
            global SUSHI_NUM
            if SUSHI_NUM <= 0:
                SUSHI_NUM += 5
                print('》》》寿司剩余为0,{}生产了5个寿司, 消费者可以吃了'.format(self.name))
                print('》》》寿司剩余:{}'.format(SUSHI_NUM))
                con.notify_all()
                con.release()
            else:
                print('》》》寿司还有,无需生产')
                con.wait()
            time.sleep(1)


# 消费者
class Consumer(threading.Thread):
    def __init__(self, name):
        super(Consumer, self).__init__()
        self.name = name

    def run(self):
        while True:
            con.acquire()
            global SUSHI_NUM
            if SUSHI_NUM > 0:
                SUSHI_NUM -= 1
                print('》》》{}吃了一个寿司'.format(self.name))
                print('》》》寿司剩余:{}'.format(SUSHI_NUM))
                con.notify_all()
                con.release()
            else:
                print('》》》寿司没有了,需要生产')
                con.wait()
            time.sleep(1)


if __name__ == '__main__':

    # 寿司数量
    SUSHI_NUM = 5

    # 条件变量
    con = threading.Condition(threading.Lock())

    # 创建三个Consumer
    for i in range(3):
        c = Consumer('Consumer-%d' % (i+1))
        c.start()

    # 创建两个Producer
    for i in range(2):
        p = Producer('Producer-%d' % (i+1))
        p.start()

结果:

》》》Consumer-1吃了一个寿司
》》》寿司剩余:4
》》》Consumer-2吃了一个寿司
》》》寿司剩余:3
》》》Consumer-3吃了一个寿司
》》》寿司剩余:2
》》》寿司还有,无需生产
》》》寿司还有,无需生产
》》》Consumer-3吃了一个寿司
》》》寿司剩余:1
》》》Consumer-1吃了一个寿司
》》》寿司剩余:0
》》》寿司剩余为0,Producer-2生产了5个寿司, 消费者可以吃了
》》》寿司剩余:5
》》》寿司还有,无需生产
》》》Consumer-2吃了一个寿司
》》》寿司剩余:4

总体理解:
1.第一个消费者执行acquire获取到锁,其他两个消费者都阻塞尝试获取锁
2.然后第一个消费者吃了1个寿司,通知等待池中线程(不过此时等待池中并没有线程),然后执行release释放锁。并sleep(1)阻塞1秒
3.接着又有消费者拿到锁,重复第一个消费者的操作。最后一个消费者也拿到锁,重复第一个消费者的操作
4.生产者线程1执行acquire获取到锁,判断寿司还剩2个,于是执行wait方法进入等待池,并释放锁
5.生产者线程2执行acquire获取到锁,判断寿司还剩2个,于是执行wait方法进入等待池,并释放锁
6.接着又有消费者拿到锁吃寿司,通知等待池中线程,也就是通知生产者。
7.但生产者判断寿司还有1个,于是继续wait,释放锁。
8.又有消费者拿到锁吃寿司,寿司剩了0个,并notify了生产者,自身执行wait,释放锁。
9.消费者接到通知,继续执行,发现寿司剩了0个,然后加了5个寿司,并通知消费者。
7.生活者消费。
8.如此循环往复。

Semaphore类

信号量对象。它比锁多了一个计数器。这个计数器主要用来计算当前剩余的锁的数量。
简而言之就是,信号量是可以规定获取次数的锁。或者说可以被多次获取锁,至于可以被获取几次,就看这个计数器,到达次数了才阻塞,而Lock是获取一次就阻塞。
调用信号量对象的acquire()方法会减少计数器,release()方法则增加计数器,计数器的值永远不会小于零,当调用acquire()时,如果发现该计数器为零,则阻塞线程,直到调用release()方法使计数器增加。

构造方法

threading.Semaphore(value=1)
value参数默认值为1,如果指定的值小于0,则会报ValueError错误。一个信号量对象管理一个原子性的计数器,代表release()方法调用的次数减去acquire()方法的调用次数,再加上一个初始值。

acquire(blocking=True, timeout=None):默认情况下,在进入时,如果计数器大于0,则减1并返回True,如果等于0,则阻塞直到使用release()方法唤醒,然后减1并返回True。被唤醒的线程顺序是不确定的。如果blocking设置为False,调用这个方法将不会发生阻塞。timeout用于设置超时的时间,在timeout秒的时间内没有获取到信号量,则返回False,否则返回True。
release():释放一个信号量,将内部计数器增加1。当计数器的值为0,且有其他线程正在等待它大于0时,唤醒这个线程。

"""
通过信号量对象管理一次性运行的线程数量
"""
import time
import threading

# 创建信号量对象,初始化计数器值为3
semaphore3 = threading.Semaphore(3)


def thread_semaphore(index):
    # 信号量计数器减1
    semaphore3.acquire()
    print('thread_{} is running...'.format(index))
    time.sleep(2)

    # 信号量计数器加1
    semaphore3.release()


if __name__ == '__main__':
    # 通过信号量控制同时有3个线程运行
    # 第4个线程启动时,调用acquire发现计数器为0了,所以就会阻塞等待计数器大于0的时候
    for i in range(9):
        threading.Thread(target=thread_semaphore, args=(i, )).start()

结果:
首先有3个线程可以运行

thread_0 is running...
thread_1 is running...
thread_2 is running...

2秒后,再有3个线程可以运行

thread_3 is running...
thread_4 is running...
thread_5 is running...

如果使用Lock,只能一次运行一个线程,其他线程都被阻塞,直到前一个线程进行release。

Barrier类

栅栏对象用于处理一批指定数量的同时调用wait方法阻塞的线程。多个线程尝试调用wait()方法,然后阻塞,当阻塞的线程达到一定数量(parties)时,会同时释放这批线程,让这批线程继续进行,并调用一个可调用对象(actions)。
可以理解为:设置个栅栏,拦够了阻塞的线程,统一放行,并调用一个可调用对象,然后继续拦。

构造方法

threading.Barrier(parties, action=None, timeout=None)

  • parties:指定需要创建的栅栏对象的线程数。
  • action:一个可调用对象,当所有线程都被释放时,在释放前,其中一个线程(随机)会自动调用这个对象。
  • timeout:设置wait()方法的超时时间。
"""
栅栏对象使用示例
"""
import time
import threading


def test_action():
    print('所有栅栏线程释放前调用此函数!')


# 创建线程数为3的栅栏对象
barrier = threading.Barrier(3, test_action)


def barrier_thread(sleep):
    time.sleep(sleep)
    print('barrier thread-%s wait...' % sleep)
    # 阻塞线程,直到阻塞线程数达到栅栏指定数量3,释放着3个线程,然后继续“拦”(如果有的话)
    barrier.wait()
    print('barrier thread-%s end!' % sleep)


def main():
    # 这里开启了6个线程
    for sleep in range(6):
        threading.Thread(target=barrier_thread, args=(sleep, )).start()


if __name__ == '__main__':
    main()

结果:

barrier thread-0 wait...
barrier thread-1 wait...
barrier thread-2 wait...
所有栅栏线程释放前调用此函数!
barrier thread-2 end!
barrier thread-1 end!
barrier thread-0 end!
barrier thread-3 wait...
barrier thread-4 wait...
barrier thread-5 wait...
所有栅栏线程释放前调用此函数!
barrier thread-5 end!
barrier thread-4 end!
barrier thread-3 end!

Queue

同进程的队列相似,可以用于多线程之间,线程安全的数据通信。
例如有个包子店,店员A在生产包子。客户B在不断的吃店员A生产的包子。A生产的速度和B消费的速度并不是一致的, 因此需要用到队列来保持一致

创建Queue

q = queue.Queue()

添加元素

q.put(item) # 如果放不进去会阻塞,直到有空间后可以放进去

获取元素

item = q.get() # 没有内容会阻塞,只能有内容可以被取出

查询状态

q.qsize() 查看元素的多少
q.empty() 判断是否为空
q.full() 判断是否已满

生产者消费者模式

import random
import threading
import time

import requests
import queue
from bs4 import BeautifulSoup


# 爬取函数
def craw(url):
    r = requests.get(url)
    return r.text


# 渲染函数
def parse(html):
    soup = BeautifulSoup(html, "html.parser")
    links = soup.find_all("a", class_="post-item-title")
    return [(link["href"], link.get_text()) for link in links]


# 从url_queue取url进行爬虫,将爬取到到html返回值存到html_queue队列中
def do_craw(url_queue: queue.Queue, html_queue: queue.Queue):
    while True:
        url = url_queue.get()
        html = craw(url)
        html_queue.put(html)
        print(threading.current_thread().name, f"craw{url}",
              "url_queue.size=", url_queue.qsize()
              )
        time.sleep(random.randint(1, 2))


# 对html_queue中对html进行解析,拿到对数据写入file中
def do_parse(html_queue: queue.Queue, file):
    while True:
        html = html_queue.get()
        results = parse(html)
        for result in results:
            file.write(str(result) + '\n')
        print(threading.current_thread().name, "results.size", len(results),
              "html_queue.size=", html_queue.qsize()
              )
        time.sleep(random.randint(1, 2))


if __name__ == '__main__':
    urls = [
        f"https://www.cnblogs.com/#p{page}"
        for page in range(1, 50 + 1)
    ]

    # 准备url_queue队列
    url_queue = queue.Queue()
    for url in urls:
        url_queue.put(url)

    # 准备html_queue队列
    html_queue = queue.Queue()

    # 创建生产者线程,进行爬取并生成html数据放入html_queue
    for idx in range(3):
        t = threading.Thread(target=do_craw, args=(url_queue, html_queue),
                             name=f"craw{idx + 1}")
        t.start()

    file = open("craw_result_file.txt", "w")

    # 创建消费者线程,对html_queue进行消费
    for idx in range(2):
        t = threading.Thread(target=do_parse, args=(html_queue, file),
                             name=f"parse{idx + 1}")
        t.start()

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Python 的多线程编程中,为了避免资源竞争和数据不一致的问题,我们需要使用同步机制来保证线程之间的协调。以下是几种常用的同步机制: 1. Lock(锁):Lock 是最基本的同步机制之一,它可以确保在同一时间只有一个线程可以访问共享资源。 2. RLock(可重入锁):RLock 是 Lock 的升级版,它允许同一个线程多次获得锁,从而避免了死锁的问题。 3. Semaphore(信号量):Semaphore 是一种计数器,它用来控制对共享资源的访问数量。当计数器为 1 时,Semaphore 就相当于 Lock;当计数器大于 1 时,Semaphore 就可以允许多个线程同时访问共享资源。 4. Condition(条件变量):Condition 是一种高级的同步机制,它允许线程在某个条件满足时被唤醒。通常情况下,Condition 和 Lock 一起使用,来实现线程间的协调和通信。 5. Event(事件):Event 是一种简单的同步机制,它允许线程在某个事件发生时被唤醒。通常情况下,Event 被用来协调多个线程的启动和停止。 6. Barrier(屏障):Barrier 是一种同步机制,它可以让多个线程在某个点上进行同步,即所有线程必须同时到达该点才能继续执行。 以上是常见的同步机制,具体使用哪种机制则根据实际需求而定。在使用多线程编程时,需要注意线程之间的协调和通信,避免出现资源竞争和数据不一致的问题。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值