线程同步

线程同步

  • 线程同步
    • 概念
    • Event
    • Lock
      • 加锁、解锁
      • 锁的应用场景
      • 非阻塞锁使用
      • 可重入锁
    • Condition
    • Barrier
      • Barrier实例:
      • Barrier应用
      • 简单理解
    • semaphore 信号量
      • 应用举例
      • 问题
      • 信号量和锁的比较
    • 数据结构和GIL

概念


线程同步,线程间协同,通过某种技术,让一个线程访问某些数据时,其它线程不能访问这些数据,知道该线程完成对数据的操作。

不同操作系统实现技术有所不同,有临界区(Critical Section)、互斥量(Mutex)、信号量(Semaphore)、时间Event等。

Event


Event事件,是线程间通信机制中最简单的实现,使用一个内部的标记flag,通过flag的变化来进行操作。

  • set() : 标记设置为True。
  • clear():标记设置为False。
  • is_set():标记是否为True。
  • wait(timeout = None):设置等待标记为True的时长,None为无限等待。等到返回True,未等到超时了返回False。

总结:

使用同一个Event对象的标记flag,谁wait就是等到flag变为True,或等到超时返回False,不限制等待的个数。

wait的使用

import threading
import logging

logging.basicConfig(level = logging.INFO)

def do(event:threading.Event, interval:int):
    while not event.wait(interval):# 条件中使用,返回True或者False
        logging.info('do sth')

e = threading.Event()
threading.Thread(target=do, args=(e,3)).start()

e.wait(10)
e.set()

print('main exit')

Lock


凡是存在共享资源争抢的地方都可以使用锁,从而保证只有一个使用者可以完全使用这个资源,一旦线程获得锁,其它试图获取锁的线程都将被阻塞。

名称 含义
acquire(blocking = True, timeout = -1) 默认阻塞,阻塞可以设置超时时间。非阻塞时,timeout禁止设置。成功获取锁,返回True,否则返回False。
release() 释放锁,可以从任何线程调用释放。已上锁的锁,会被重置为unlocked。在未上锁的锁上调用,会抛RuntimeError异常。

加锁、解锁

一般来说,加锁就需要解锁,但是加锁后解锁前,还要有一些代码执行,就有可能抛异常,一旦出现异常,锁是无法释放,但是当前线程可能因为这个异常被终止了,这就产生了死锁。

加锁、解锁常用语句:

  1. 使用try…finally 语句保证锁的释放。
  2. with上下文管理器,锁对象支持上下文管理。

锁的应用场景

使用锁的注意事项:

  • 少用锁,必要时用锁。使用了锁,多线程访问被锁的资源时,就成了串行,要么排队执行,要么争抢执行。
    • 举例,高速公路上车并行跑,可是到了省界只有一个收费口,过了这个收费口,车辆依然可以在车道上一起跑。过收费口的时候,如果排队一辆辆过,加不加锁一样效率相当,但是一旦出现争抢,就必须加锁一辆辆过。
  • 加锁时间越短越好,不需要就立即释放锁。
  • 一定要避免死锁。

非阻塞锁使用

import threading
import logging
import time

FORMAT = '[%(threadName)s, %(thread)8d] %(message)s'
logging.basicConfig(level = logging.INFO, format= FORMAT)

def worker(tasks):
    for task in tasks:
        time.sleep(0.001)
        if task.lock.acquire(False):# 获取锁则返回True
            logging.info('{} {} begin to stsrt'.format(threading.current_thread(), task.name))
            # 适当的时机释放锁,为了演示不释放
        else:
            logging.info('{} {} is working'.format(threading.current_thread(), task.name))

class Task:
    def __init__(self, name):
        self.name = name
        self.lock = threading.Lock()  # 每个任务都有一把自己的锁

# 构造5个任务
tasks = [Task('task - {}'.format(x)) for x in range(5)]

# 启动3个线程
for i in range(3):
    threading.Thread(target = worker, name = 'worker-{}'.format(i), args = (tasks,)).start()


#####################输出结果:
# [worker-0,    10492]  task - 0 begin to stsrt
# [worker-2,     4356]  task - 0 is working
# [worker-1,     8272]  task - 0 is working
# [worker-0,    10492]  task - 1 begin to stsrt
# [worker-2,     4356]  task - 1 is working
# [worker-2,     4356]  task - 2 begin to stsrt
# [worker-0,    10492]  task - 2 is working
# [worker-1,     8272]  task - 1 is working
# [worker-1,     8272]  task - 2 is working
# [worker-0,    10492]  task - 3 begin to stsrt
# [worker-2,     4356]  task - 3 is working
# [worker-2,     4356]  task - 4 begin to stsrt
# [worker-0,    10492]  task - 4 is working
# [worker-1,     8272]  task - 3 is working
# [worker-1,     8272]  task - 4 is working

可重入锁

可重入锁,是线程相关的锁,线程A获得可重复锁,并可以多次成功获取,不会阻塞,最后要在线程A中做和acquire次数相同的release。当锁未释放完,其他线程获取锁就会阻塞,直到当前持有锁的线程释放完锁。上锁后显示信息为:

import threading
import time

lock = threading.RLock()
print(lock.acquire())   # True
print('----------')
print(lock.acquire(blocking=False))   # True,是否设置blocking都不会阻塞
print(lock.acquire())   # 直接返回True,RLock在同一线程中不会阻塞
print(lock.acquire(timeout = 3.55))   # True,即使设置了超时,也是立即获取锁,不会等待
#print(lock.acquire(blocking =False, timeout = 10)) #异常,blocking 设置了false,无法继续设置timeout
lock.release()
lock.release()
lock.release()
print('main thread {}'.format(threading.current_thread().ident))
print('lock in main thread {}'.format(lock)) # 注意观察lock对象的信息,锁的属主是主线程的ID
lock.release()
#lock.release() # 多了一次,执行会触发异常
print('=====================')

print(lock.acquire(blocking=False)) # 上锁一次
# threading.Timer(3, lambda x:x.release(), args=(lock,)).start() #跨线程,会触发异常,只能在当前线程释放
lock.release()
print('+++++++++++++++')

# 测试多线程
print(lock.acquire())
def sub(l):
    print('lock in main thread {}'.format(lock)) # 此时锁的属主依旧是主线程
    print('{}: {}'.format(threading.current_thread(), l.acquire()))  # 因为主线程锁为释放会进入阻塞
    print('{}: {}'.format(threading.current_thread(), l.acquire(False)))
    print('lock in sub thread {}'.format(lock)) # 此时锁的属主换为当前线程
    l.release()
    print('sub 1')
    l.release()
    print('sub 2')
    # l.release() # 多释放一次

threading.Timer(2,sub,args= (lock,)).start() #传入同一个lock对象,此时主线程中仍有一把处于上锁状态
print('++++++')

time.sleep(5) # 确保子线程已启动,此时子线程处于阻塞状态
lock.release() # 释放后才会执行子线程中被阻塞的语句

Condition


构造方法Condition(lock=None),可以传入一个Lock或RLock对象,默认是Rlock。

名称 含义
acquire(*args) 获取锁
wait(self, timeout=None) 等待或超时
notify(n=1) 唤醒至多指定数目个数的等待的线程,没有等待的线程就没有任何操作
notify_all() 唤醒所有等待的线程

Condition用于生产者、消费者模型,为了解决生产者速度匹配问题。

from threading import Thread, Event, Condition
import logging
import random

FORMAT = '%(asctime)s %(threadName)s %(thread)d %(message)s'
logging.basicConfig(format=FORMAT, level=logging.INFO)

class Dispatcher:
    def __init__(self):
        self.data = None
        self.event = Event()  # 方便即时和判断consumer进入工作状态条件
        self.cond = Condition()

    def produce(self, total):
        for _ in range(total):
            self.event.wait(1)  # 相当于休眠1秒,模拟生产所花费时间
            data = random.randint(0, 100)
            with self.cond:
                logging.info(data)
                self.data = data
                self.cond.notify_all()  # 唤醒所有阻塞中的consume线程
        self.event.set()

    def consume(self):
        while not self.event.is_set():
            with self.cond:
                if self.cond.wait(): # 进入阻塞状态,等待唤醒,如果生产者不是全部唤醒的话会造成部分线程阻塞,可以设置timeout值。
                    logging.info('received {}'.format(self.data))
                    self.event.wait(0.5)

d = Dispatcher()
p = Thread(target=d.produce, args=(10,), name='producer')

for i in range(5):
    Thread(target=d.consume, name='consumer-{}'.format(i)).start()

p.start()

注:上例中,程序本身不是线程安全的,程序逻辑有很多瑕疵,但是可以很好的帮助理解Condide使用,和生产者消费者模型。

总结

Condition用于生产者消费者模型中,解决生产者消费者速度匹配的问题,采用了通知机制,可以提高效率。

使用方式:

使用condition,必须先acquire,用完了要release,因为内部使用了锁,默认使用RLock锁,最好的方法是使用with上下文管理。

消费者wait,等待通知。

生产者生产好了消息,对消费者发通知,可以使用notify或者notify_all方法。

Barrier


直译名为栅栏,屏障,路障,道闸。

名称 含义
Barrier(parties, action=None, timeout=None) 构建Barrier对象,指定参与方数目。timeout是wait方法未指定超时的默认值。
n_waiting 当前在屏障中等待的线程数。
parties 各方数目,就是需要多少个等待。
wait(timeout=None) 等待通过屏障,返回0到线程数-1的整数,每个线程返回不同。如果wait方法设置了超时,并超时发送,屏障将处于broken状态。

Barrier实例:

import threading
import logging

FORMAT = '[%(threadName)s, %(thread)8d] %(message)s'
logging.basicConfig(level=logging.INFO, format=FORMAT)

def worker(barrier:threading.Barrier):
    logging.info('waiting for {} threads.'.format(barrier.n_waiting))
    try:
        barrier_id = barrier.wait() # 等待通过屏障,人齐就过,设置时间的话,到时间会处于broken状态。
        logging.info('after barrier {}'.format(barrier_id))
    except threading.BrokenBarrierError:
        logging.info('Broken Barrier')

barrier = threading.Barrier(3)

for x in range(3): # 设置不是Barrier数目倍数的话,且实例wait未设置时间的话,会阻塞
    threading.Thread(target=worker, name='worker {}'.format(x), args=(barrier,)).start()

logging.info('start')

### 输出为:
# [worker 0,     3840] waiting for 0 threads.
# [worker 1,     5864] waiting for 1 threads.
# [worker 2,     9292] waiting for 2 threads.
# [MainThread,    12464] start
# [worker 0,     3840] after barrier 0
# [worker 2,     9292] after barrier 2
# [worker 1,     5864] after barrier 1

从运行结果看出:

所有线程冲到了Barrier前等待,知道到达parties的数目,屏障打开,所有线程停止等待,继续执行。再有线程wait,屏障就绪等到到达参数方数目。举例,赛马比赛所有马匹就位,开闸。下一批马屁陆续来到栅门前等待比赛。

名称 含义
broken 如果屏障处于打破的状态,返回True。
abort() 将屏障至于broken状态,等待中的线程或者调用等待方法的线程中都会抛出BrokenBarrierError异常,直到reset。
reset() 恢复屏障,重新开始拦截。

对上面的代码进行更改,启动线程那里改为:

for x in range(0,9):
    if x == 2:   # x为2时,将屏障设为broken状态,等待中的线程和以后的线程都将抛出BrokenBarrierError异常,直到seset方法恢复屏障。
        barrier.abort()
    elif x == 6:
        barrier.reset() # 恢复屏障,启动后,时间很短,所以id号为5,6,7可以正常完成等待执行,而id为8的进程会造成阻塞,解决办法是将等待时间提到判断前面,可以将本次例子正常完成。
    threading.Event().wait(1)
    threading.Thread(target=worker, name='worker {}'.format(x), args=(barrier,)).start()

Barrier应用

并发初始化。
所有线程都必须初始化完成后,才能继续工作,例如运行前加载数据、检查,如果这些工作没完成,就开始运行,将不能正常工作。
10个线程完成10种工作准备,每个线程负责一种工作,只有这10个线程完成后,才能继续工作,先完成的要等待后完成的线程。
例如,启动一个程序,需要先加载磁盘文件、缓存预热、初始化连接池等工作,这些工作可以齐头并进,不够只有都满足了,程序才能继续向后执行。假设数据库连接失败,则初始化工作失败,就要abort,barrier置为broken,所有线程收到异常退出。

简单理解

将barrier理解为栅栏,初始化设置的是等到几个目标齐了再开栅栏,开了后再合上等待下一批,设置的timeout超时是等多久还没齐的话栅栏前等待的这些就会等不及进行破坏导致栅栏损坏,直接触发异常,栅栏坏掉的话只能等待reset方法修补栅栏。类实例的wait方法是将线程至于栅栏前,处于等待人齐为止,也可以设置超时,n_waiting属性是栅栏前处于等待的数目,parties属性是栅栏开放需要满足的数目,broken属性是查看栅栏好坏状态,打破状态的话返回True,abort方法是人工干预直接将栅栏破坏,而reset方法是对栅栏进行修复,使broken回复到False状态。

semaphore 信号量


和Lock很像,信号量对象内部维护一个倒记数器,每一次acquire都会减1,当acquire方法发现计数为0就阻塞请求的线程,知道其他线程信号量release后,计数大于0,恢复阻塞的线程。

名称 含义
Semaphore(value=1) 构造方法。value小于0,抛ValueError异常。
acquire(blocking=True, timeout=None) 获取信号量,计数器减1,获取成功返回True。
release() 释放信号量,计数器加1

应用举例

连接池

因为资源有限,且开启一个连接成本高,所以,使用连接池。
一个简单的连接池:连接池应该有容量(总数),有一个工厂方法可以获取连接,能够把不用的连接返回,供其它调用者使用。

class Conn: # 定义连接类
    def __init__(self, name):
        self.name = name 

class Pool: 
    def __init__(self, count:int):
        self.count = count # 连接数量
        self.pool = [self._connect('conn-{}'.format(x)) for x in range(self.count)] # 池中是连接对象的列表

    def _connect(self, conn_name): # 构建连接对象
        return Conn(conn_name) 

    def get_conn(self): # 从池中拿走一个连接
        if len(self.pool) > 0:
            return self.pool.pop() 

    def return_conn(self,conn:Conn): # 向池中加入一个连接
        self.pool.append(conn)

而真正打连接池相比较上面的例子要复杂的多,这里只是简单的一个功能的实现,本例中,get_conn()方法在多线程的时候有线程安全问题。
假设池中正好有一个连接,有可能多个线程判断池的长度是大于0的,当一个线程拿走了连接池对象,其他线程再来pop就会抛出异常的。如何解决?

  1. 加锁,在读写的地方上加锁。
  2. 使用信号量Semaphore。

使用信号量对上例进行修改

import threading
import logging
import random

FORMAT = '%(asctime)s %(thread)d %(threadName)s %(message)s'
logging.basicConfig(format=FORMAT, level=logging.INFO)

class Conn: # 定义连接类
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return self.name

class Pool:
    def __init__(self, count:int):
        self.count = count # 连接数量
        self.pool = [self._connect('conn-{}'.format(x)) for x in range(self.count)] # 池中是连接对象的列表
        self.semaphore = threading.Semaphore(count)

    def _connect(self, conn_name): # 构建连接对象
        return Conn(conn_name)

    def get_conn(self): # 从池中拿走一个连接
        print('------')
        self.semaphore.acquire()
        print('======')
        conn = self.pool.pop()
        return conn

    def return_conn(self,conn:Conn): # 向池中加入一个连接
        self.pool.append(conn)
        self.semaphore.release()

pool = Pool(3)

def worker(pool:Pool):
    conn = pool.get_conn()
    logging.info(conn)
    threading.Event().wait(random.randint(1,4))
    pool.return_conn(conn)

for i in range(5):
    threading.Thread(target=worker, name='worker {}'.format(i), args=(pool,)).start()

上例中,使用信号量解决资源有限的问题。如果池中有资源,请求者获取资源时信号量减1,拿走资源。当请求超过资源数,请求者只能等待。当使用者用完归还资源后信号量加1,等待线程就可以被唤醒拿走资源。

注意:这个例子不能用到生产环境,只是为了说明信号量使用的例子,还有很多未完成功能。

问题

self.conns.append(conn)这一句要不要加锁?

从程序逻辑上分析

  1. 假设如果还没有使用信号量,就release,会怎么样?
import threading
import logging

sema = threading.Semaphore(3)
logging.warning(sema.__dict__) 
# WARNING:root:{'_cond': , 0)>, '_value': 3}
for i in range(3):
    sema.acquire()
logging.warning('~~~~~~~~~~~~~~~')
logging.warning(sema.__dict__) #  '_value': 0

for i in range(4):
    sema.release()
logging.warning(sema.__dict__) # '_value': 4    超过设置的数值3

for i in range(3):
    sema.acquire()
logging.warning('~~~~~~~~~~~~~~~')
logging.warning(sema.__dict__) # '_value': 1     4-3=1
sema.acquire()
logging.warning('~~~~~~~~~~~~~~~')
logging.warning(sema.__dict__) # '_value': 0

从上例输出结果可以看出,内置计数器达到了4,这样实际超出了设置的最大值,需要解决。

BoundedSemaphone类

有界的信号量,不允许使用release超出初始值的范围,否则,抛出ValueError异常。

这样用有界信号量修改源代码,保证如果多return_conn就会抛出异常,保证了多归还连接抛出异常。

如果归还了同一个连接多次怎么办,去重很容易判断出来。

  1. 如果使用了信号量,但是还没有用完

假设一种极端情况,计数器还差1就满了,有三个线程A、B、C都执行了第一句,都没来得及release,这时候轮到线程A release,正常的release, 然后轮到线程C先release,一定出问题,超界了,直接抛异常。因此信号量,可以保证,一定不能多归还。

  1. 很多线程用完了信号量

没有获得信号量的线程都阻塞,没有线程和归还的线程争抢,当append后才release,这时候才能等待的线程被唤醒,才能pop,也就是没有获取信号量就不能pop,线程是安全的。

信号量和锁的比较

锁,只允许同一个时间一个线程独占资源,它是特殊的信号量,即信号量计数器初值为1。

信号量,可以多个线程访问共享资源,但这个共享资源数量有限。

锁,可以看作特殊的信号量。

数据结构和GIL


Queue

标准库queue模块,提供FIFO的Queue、LIFO的队列、优先队列。

Queue类是线程安全的,适用于多线程间安全的交换数据,内部使用了Lock和Condition。然而,Queue类的size虽然加了锁,但是,依然不能保证立即get、put就能成功,因为读取大小和get、put方法是分开的。

import queue

q = queue.Queue(8)

if q.qsize() == 7:
    q.put() # 上下两句可能被打断
if q.qsize() == 1:
    q.get() # 未必会成功

GIL全局解释器锁

CPython在解释器进程级别有一把锁,佳作GIL全局解释器锁。
GIL保证CPython进程中,只有一个线程执行字节码,甚至在多核CPU的情况下,也是如此。

CPython中

IO密集型,由于线程阻塞,就会调度其他线程;
CPU密集型,当前现场给你可能会连续的获得GIL,导致其他线程几乎无法使用CPU。
在CPython中由于有GIL存在,IO密集型,使用多线程;CPU密集型,使用多进程,避开GIL。

新版CPython正在努力优化GIL的问题,但不是移除。如果非要使用多线程的效率问题,请绕行,选择其他语言如erlang、Go等。

Python中绝大多数内置数据结构的读写都是原子操作。
由于GIL的存在,Python的内置数据类型在多线程编程的时候就变成了安全的了,但是实际上他们本身不是线程安全类型的。

保留GIL的原因:

Guido坚持的简单哲学,对于初学者门槛低,不需要高深的系统知识也能安全、简单的使用Python。
而且移除GIL,会降低CPython单线程的执行效率。

你可能感兴趣的:(python)