38.4-barrier、semaphore和GIL

没有人会为你的贫穷负责,却有人为你的富有而喝彩,所以不要活在别人的嘴巴里,请做好自己。有路就大胆的去走,有梦就大胆的飞翔,若要成功,就要大胆去闯,大胆尝试才是信仰。不敢做,不去闯,梦想就会变成幻想,前行的路不怕万人阻挡,只怕自己投降,人生的帆,不怕狂风巨浪,只怕自己没胆量!!

有目标的人睡不着,沒目标的人睡不醒,容易走的都是下坡路。埋怨是懦弱的表现,努力才是人生的应有态度,睁开眼就是新的开始,时常鼓励一下自己,对自己说该奋斗了!

38.4-barrier、semaphore和GIL_第1张图片

总结:

  1. 和Lock很像,信号量和锁都是解决资源有限的问题的;
  2. 最常见的池:连接池、线程池;
  3. GIL全局解释器锁(面试常问):假并行的本质、解决方案;*GIL是进程级别的锁,你可以绕开它(使用多进程);
  4. 线程安全的类型目前只有 queue ; 内置数据类型 其实不是线程安全的;
  5. 欠的债都是要还的;
  6. Python大力发展协程;

1. semaphore 信号量

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

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

import threading
import logging
import time

# 输出格式定义
FORMAT = '%(asctime)-15s\t [%(threadName)s, %(thread)8d] %(message)s'
logging.basicConfig(level=logging.INFO, format=FORMAT)

def worker(s: threading.Semaphore):
    logging.info('in sub thread')
    logging.info(s.acquire())  # 阻塞
    logging.info('sub thread over')

# 信号量
s = threading.Semaphore(3)
logging.info(s.acquire())
print(s._value)
logging.info(s.acquire())
print(s._value)
logging.info(s.acquire())
print(s._value)

threading.Thread(target=worker, args=(s,)).start()
time.sleep(2)

logging.info(s.acquire(False))
logging.info(s.acquire(timeout=3))  # 阻塞3秒

# 释放
logging.info('released')
s.release()
#---------------------------------------------------------------------
2020-01-14 20:28:43,564  [MainThread,    17620] True
2020-01-14 20:28:43,564  [MainThread,    17620] True
2020-01-14 20:28:43,564  [MainThread,    17620] True
2020-01-14 20:28:43,564  [Thread-1,    19392] in sub thread
2
1
0
2020-01-14 20:28:45,564  [MainThread,    17620] False
2020-01-14 20:28:48,565  [MainThread,    17620] False
2020-01-14 20:28:48,565  [MainThread,    17620] released
2020-01-14 20:28:48,565  [Thread-1,    19392] True
2020-01-14 20:28:48,565  [Thread-1,    19392] sub thread over

elease方法超界问题
假设如果还没有acquire信号量,就release,会怎么样?

2. BoundedSemaphore类

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

将上例的信号量改成有界的信号量试一试。

应用举例

连接池
因为资源有限,且开启一个连接成本高,所以,使用连接池。

一个简单的连接池
连接池应该有容量(总数),有一个工厂方法可以获取连接,能够把不用的连接返回,供其他调用者使用。

场景:要频繁的创建资源;

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(level=logging.INFO, format=FORMAT)


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(count)]
        self.semaphore = threading.Semaphore(count)  # threading.Semaphore()

    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(6):
    threading.Thread(target=worker, name="worker-{}".format(i),args=(pool,)).start()
#------------------------------------------------------------------------------------

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

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

3. 问题

self.conns.append(conn) 这一句有哪些问题考虑?

1、边界问题分析

return_conn方法可以单独执行,有可能多归还连接,也就是会多release,所以,要用有界信号量BoundedSemaphore类。

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

self.pool.append(conn)
self.semaphore.release()

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

如果归还了同一个连接多次怎么办,重复很容易判断。
这个程序还不能判断这些连接是不是原来自己创建的,这不是生成环境用的代码,只是简单演示。

2、正常使用分析

正常使用信号量,都会先获取信号量,然后用完归还。
创建很多线程,都去获取信号量,没有获得信号量的线程都阻塞。能归还的线程都是前面获取到信号量的线程,其他没有获得线程都阻塞着。非阻塞的线程append后才release,这时候等待的线程被唤醒,才能pop,也就是没有获取信号量就不能pop,这是安全的。

经过上面的分析,信号量比计算列表长度好,线程安全。

信号量和锁
,只允许同一个时间一个线程独占资源,他是特殊的信号量;
信号量,可以多个线程访问共享资源,但这个共享资源数量有限;
,可以看做特殊的信号量;

4. 数据结构和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() # 未必会成功

5. GIL全局解释器锁***(面试常问)

CPython 在解释器进程级别有一把锁,叫做GIL 全局解释器进程锁

(假并行运行的本质:)GIL 保证CPython进程中,只有一个线程执行字节码。甚至是在多核CPU的情况下,也是只能允许一个CPU上的一个线程在运行。(时间片非常短)

CPython中
IO密集型,由于线程阻塞,就会调度其他线程;
CPU密集型,当前线程可能会连续的获得GIL,导致其它线程几乎无法使用CPU。
假并行运行解决方案:在CPython中由于有GIL存在,IO密集型,使用多线程较为合算;CPU密集型,使用多进程,要绕开GIL

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

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

保留GIL的原因:
Guido坚持的简单哲学,对于初学者门槛低,不需要高深的系统知识也能安全、简单的使用Python。
而且移除GIL,会降低CPython单线程的执行效率。
测试下面2个程序,请问下面的程序是计算密集型还是IO密集型?

import logging,threading
import datetime

logging.basicConfig(level=logging.INFO, format="%(thread)s %(message)s")
start = datetime.datetime.now()
# 计算
def calc():
    sum = 0
    for _ in range(10000):
        sum += 1

calc()
calc()
calc()
calc()
calc()

delta = (datetime.datetime.now() - start).total_seconds()
logging.info(delta)
#-------------------------------------------------------------
9328 0.00197
import threading
import logging
import datetime

logging.basicConfig(level=logging.INFO, format="%(thread)s %(message)s")
start = datetime.datetime.now()

# 计算
def calc():
    sum = 0
    for _ in range(1000000000):
        sum += 1

t1 = threading.Thread(target=calc)
t2 = threading.Thread(target=calc)
t3 = threading.Thread(target=calc)
t4 = threading.Thread(target=calc)
t5 = threading.Thread(target=calc)
t1.start()
t2.start()
t3.start()
t4.start()
t5.start()
t1.join()
t2.join()
t3.join()
t4.join()
t5.join()
delta = (datetime.datetime.now() - start).total_seconds()
logging.info(delta)
#------------------------------------------------
18056 194.114758

注意,不要在代码中出现print等访问IO的语句;

从两段程序测试的结果来看,CPython 中多先后才能根本没有任何优势,和一个线程执行时间相当,因为GIL的存在,尤其是像上面的计算密度型程序;

你可能感兴趣的:(38.4-barrier、semaphore和GIL)