python 线程锁 和 GIL 2019-10-22(未经允许禁止转载)

线程安全

多线程在调度切换的过程中,不会对同一对象产生二义性的操作,就是线程安全

非线程安全

通过例子理解比较直观
比如A B两个人买演唱会的票,A B都看到还有最后一张票,于是点击购买,服务器用两个线程去处理这两个购买请求。如果非线程安全,那么有可能出现的这样的情况:A 线程访问票池,发现剩余1张票,判断可以出票,刚准备出票的时候,由于系统资源的调度,A线程阻塞,票没卖出去。此时B线程活动,访问票池,也发现剩余1张票(因为A线程没卖出去,还是原来的余票),于是进行出票,1 - 1 = 剩余0张票。卖完之后B线程完成任务,生命周期结束。此时A线程从阻塞状态恢复到运行状态,继续卖票给A用户,0 -1 = 剩余 -1 张票。。不仅出现了剩余 -1 张票这样的不合理情况,而且A B买到同一张票,不知道谁的票是真的,大打出手,这就大祸了。这就是非线程安全的血的教训

分析一下出现这样情况的原因

  • A线程在卖票过程中“睡着了(被阻塞)”
  • 然后B线程趁A线程睡觉把票卖出去
  • 等A线程睡醒的时候,已经时过境迁,但A线程没有发现,继续卖票

A线程在干活的时候,中途被阻塞了;然后B线程闯进A线程的工作区干B线程自己的活,但对A线程来说,B就是捣乱;后来A线程继续干活的时候,它的工作区状态就可能被B线程改变,这就是可能出问题的地方

保障线程安全的机制--同步

Cpython中的全局解释器锁

在Cpython解释器中,即使一个进程下开启多线程,同一时刻也只能有一个线程执行,无法利用多核优势实现多线程
注意:GIL并不是Python的特性,它实际上是某些python解释器的一种特性,如典型的Cpython带有GIL,而Jpython则没有。GIL与解释器有关,而与python语言本身无关

官方文档说明如下:

Thread State and the Global Interpreter Lock

The Python interpreter is not fully thread-safe. In order to support multi-threaded Python programs, there’s a global lock, called the global interpreter lock or GIL, that must be held by the current thread before it can safely access Python objects. Without the lock, even the simplest operations could cause problems in a multi-threaded program: for example, when two threads simultaneously increment the reference count of the same object, the reference count could end up being incremented only once instead of twice.

Therefore, the rule exists that only the thread that has acquired the GIL may operate on Python objects or call Python/C API functions (只有获得了GIL的python线程才可以操作python对象以及调用Python/C API). In order to emulate concurrency of execution, the interpreter regularly tries to switch threads (see sys.setswitchinterval()). The lock is also released around potentially blocking I/O operations like reading or writing a file, so that other Python threads can run in the meantime.

理解下来就是:

你们程序有你们程序自己定义的变量需要加锁保护
举一个大家都懂的例子,例如在程序中定义了变量i=1,且开了多个线程操作i,那么i显然需要加锁mutex保护,防止线程的操作产生冲突

那么,我python解释器也是代码也是程序啊,我python解释器也有自己的一些解释器变量abcd需要保护吧。你们哪个线程需要我解释执行的,肯定会访问我解释器的若干变量,存一些数据啊、状态啊啥的;但是我解释器的变量太多了,不想一个个地上小锁,干脆就来一把大锁,全给锁上,这就是GIL

如图,访问程序中的变量需要获取mutex锁,访问解释器中的变量则需要GIL。一个线程要执行,必然要访问解释器变量,必然需要获取GIL


GIL

由于python GIL的存在(Global Interpreter Lock,全局解释器锁),使得 一个python进程 在任何时刻 都只有一个线程在一个CPU核心上工作,无法使用多个CPU核心真正同时地进行多个线程的并行计算。例如,假设一个python进程 P 拥有100个线程并分布在100个核心上,各核心快速地在各个线程间来回切换,造成多线程假象,但一个时刻只有一个核心上的一个线程可以获得GIL从而得以运行,本质上是单线程、伪多线程

通过代码验证一下。我的cpu是4逻辑核心,创造4个死循环线程,观测cpu利用率

#!/usr/bin/env python
# coding=utf-8
import threading
from multiprocessing import Process


def noEndLoop():
    while True:
        continue

def makeThread():
    # 本机cpu是物理双核逻辑4核,所以生成3个线程+主线程 = 4线程,每个逻辑核心丢一个线程
    for i in range(3):
        t = threading.Thread(target=noEndLoop)
        t.start()

def makeProcess():
    # 本机cpu是物理双核逻辑4核,所以生成3个进程+当前进程 = 4进程,每个逻辑核心丢一个进程
    for i in range(3):
        p = Process(target=noEndLoop)
        p.start()

if __name__ == '__main__':
    makeThread()
    # makeProcess()
    
    while True:
        continue

结果如图所示。可以看到,cpu的利用率在15%左右,说明4个逻辑核心并没有同时被4个死循环线程填满,验证了python的伪多线程


python多线程(伪多线程)测试

ps:既然一个python进程不能多核并行多线程,为了让python也能够充分利用多个cpu核心,可以把任务分解成多个进程来完成,通过多进程实现多核并行计算。每个python进程拥有独立的GIL,进程之间互不影响

继续通过代码验证。修改一下上面多线程测试的代码,创造4个死循环进程对应4个逻辑核心,观测cpu利用率

#!/usr/bin/env python
# coding=utf-8
import threading
from multiprocessing import Process


def noEndLoop():
    while True:
        continue

def makeThread():
    # 本机cpu是物理双核逻辑4核,所以生成3个线程+主线程 = 4线程,每个逻辑核心丢一个线程
    for i in range(3):
        t = threading.Thread(target=noEndLoop)
        t.start()

def makeProcess():
    # 本机cpu是物理双核逻辑4核,所以生成3个进程+当前进程 = 4进程,每个逻辑核心丢一个进程
    for i in range(3):
        p = Process(target=noEndLoop)
        p.start()

if __name__ == '__main__':
    # makeThread()
    makeProcess()
    
    while True:
        continue

结果是:


python多进程测试

cpu的利用率100%,说明当前每个逻辑核心都被填满。因此python还是可以通过多进程实现多核心利用的

同步机制保证线程安全

之前说到,A线程干活到一半休息的时候,B线程进A的工作区干活,就可能改变A工作区的状态从而影响A结束休息后继续干活的正确性

那么,我们只需要A进入工作区干活的时候,就给工作区上个锁,只要A不放锁,任何线程都进不来这个工作区,那么A就算睡觉(被阻塞),也不用担心工作区被别的线程改变的问题

我们把这种实现线程安全的机制称作同步机制
对于同步,我的理解是,同步是对工作区而言的即:对任意一个线程,其阻塞前和恢复运行时的工作区必须保持一致

同时,需要注意的是,不仅为了防止别的线程修改工作区才需要同步;另一方面,为了保证线程读的时候能够读到最新的值,也需要同步。因为如果不进行同步,当线程A在cpu寄存器中完成对某个值的操作但还未写回内存时,线程B尝试访问这个值,就很可能读到CPU寄存器缓存中的旧值

在python和其他的一些语言中,一般都是通过加锁实现同步

程序实例

定义一个继承threading.Thread的MyThread_Common_Lock类,用于产生线程对象。该类的run()方法的部分代码块需要获取锁才能执行

定义test_common_lock()方法,可以产生5个MyThread_Common_Lock线程对象

main方法则调用test_common_lock()

这里要仔细看代码和输出信息,好好体会。输出的信息我事后加上了注释,方便阅读

#!/usr/bin/env python
# coding=utf-8
import threading
import time

num1 = 0
# 初始化一个线程锁头,叫mutex
mutex = threading.Lock()

class MyThread_Common_Lock(threading.Thread):
    def run(self):       
        global num1
        print('entering %s , and going to get lock ---- mutex' % threading.current_thread().name)
        if mutex.acquire(1):
            print('%s lock success' % threading.current_thread().name)
            # sleep不会释放锁,但是sleep会让线程进入阻塞状态。不过,即使我睡眠了,我也拿着这段代码块的锁牢牢不放手,其他线程想趁我睡觉的时候运行这段代码是不阔能的
            t1 = time.time()
            print('%s begin sleeping at %s' % (threading.current_thread().name, str(t1)))
            time.sleep(1)  
            print('%s end sleeping after %s seconds' % (threading.current_thread().name, str(time.time()-t1)))
            while num1 < 3:
                print('%s is operating num1' % threading.current_thread().name)
                num1 += 1
                print(num1)
            mutex.release()
            print('%s end and quit' % threading.current_thread().name)
        
        # 这个else其实根本不会执行,因为线程拿不到mutex锁就直接阻塞了,不会走到else这里来;而一旦线程获得了锁,就开始执行锁内代码块,直到线程结束退出,更加不会走到else来
        else:
            print('%s lock fail' % threading.current_thread().name)


def test_common_lock():
    print('enter main thread')
    for i in range(5):
        t = MyThread_Common_Lock()
        t.start()
        print('return to main thread for %s times' % str(i+1))
    print('end main thread')

if __name__ == '__main__':
    test_common_lock()

运行结果如下:

# 进入主线程
enter main thread
# 主线程创建线程1,进入线程1,尝试获取锁
entering Thread-1 , and going to get lock ---- mutex
# 此时控制权突然回到主线程
return to main thread for 1 times
# 线程1成功获取锁,说明控制权又交换了,回到线程1
Thread-1 lock success
# 于是控制权回到主线程,并创建了线程2,线程2尝试获取锁,但会被阻塞,因为锁在线程1手上
entering Thread-2 , and going to get lock ---- mutex
# 线程1获取锁后sleep,进入阻塞状态。其实看到这里已经发现,CPU控制权疯狂在几个线程间来回切换
Thread-1 begin sleeping at 1571757616.4769874
# 线程1阻塞后,主线程获得控制权
return to main thread for 2 times
# 主线程创建了线程3,线程3尝试获取锁,但同样会被阻塞,因为锁在线程1手上
entering Thread-3 , and going to get lock ---- mutex
return to main thread for 3 times
entering Thread-4 , and going to get lock ---- mutex
return to main thread for 4 times
entering Thread-5 , and going to get lock ---- mutex
return to main thread for 5 times
# 注意,在这里,主线程结束了一生
end main thread
# 注意,主线程结束后,子线程还在继续,后继有人!!!
Thread-1 end sleeping after 1.0009186267852783 seconds
Thread-1 is operating num1
1
Thread-1 is operating num1
2
Thread-1 is operating num1
3
Thread-1 end and quit
# 从上面几行输出可以看到,在线程1sleep的时候,没有任何一个线程可以执行这段红色框选的加了锁的代码块中的代码。只有线程1结束之后,其他线程拿到这段代码块的锁,才能执行这部分代码。这样,也就保护了线程的工作区是同步的

加锁代码块.png

# 线程1释放锁,结束生命,线程2拿到锁
Thread-2 lock success
Thread-2 begin sleeping at 1571757617.4800913
Thread-2 end sleeping after 1.0004162788391113 seconds
Thread-2 end and quit
Thread-3 lock success
Thread-3 begin sleeping at 1571757618.4819968
Thread-3 end sleeping after 1.0006229877471924 seconds
Thread-3 end and quit
Thread-4 lock success
Thread-4 begin sleeping at 1571757619.4840415
Thread-4 end sleeping after 1.0004642009735107 seconds
Thread-4 end and quit
Thread-5 lock success
Thread-5 begin sleeping at 1571757620.485518
Thread-5 end sleeping after 1.000976324081421 seconds
Thread-5 end and quit

你可能感兴趣的:(python 线程锁 和 GIL 2019-10-22(未经允许禁止转载))