Python 线程的门道

二两:观前提示,本文设计到 Python 解释器级别源码,比较枯燥,但包含了 Python 线程绝大部分的知识,感兴趣的同学,enjoy

文章目录

  • 1.Python 中的多线程

  • 2. 线程与进程

  • 3. 同步与异步

  • 4. 并发与并行

  • 5. 多线程的基本使用

  • 6. 锁

  • 7. 可重入锁

  • 8. 条件锁

  • 9. 信号量

  • 10. 事件通知

  • 11. 栅栏对象

  • 12. 使用队列同步线程

  • 13.ThreadLocal

  • 14. 线程存在的问题

  • 15. 详解 GIL

  • 16. 简析线程源码

1.Python 中的多线程

读者也许听说过,在 Python 中无法实现真正的多线程。为什么会有这种说法呢?为了解答这个问题,我们需要了解一下 Python 多线程和 GIL 锁与 Python 运行效率之间的关系,并从 Python 代码层面进行简单剖析。

2. 线程与进程

要理解多线程,先要了解一下操作系统中的线程与进程这两个概念。

当用户在操作系统中启动某一个程序时,相当于启动了至少一个进程,也就是说,一个程序在运行状态时,操作系统至少会为其创建一个进程。需要注意的是,程序是一种静态资源实体,它只是一系列代码,是一种字符文件,是静态的。程序这个词本身没有任何运行的含义。然而,进程是动态的,当操作系统将程序数据载入内存时,创建一个动态实体,它可以对各种资源进行操作,也就是说,进程反映了程序在一定数据集上运行的动态过程。

不同进程之间不共享内存。例如,QQ 是一个独立的进程,微信也是一个独立的进程,在微信中无法对 QQ 中的联系人进行操作,因为微信无法直接读取 QQ 进程中的信息。

一个进程可以包含至少一个或多个线程。如果进程由多个线程构成,此时就是一个多线程状态,这些线程共享进程中的内存空间,相互配合,完成进程的任务。但是,也正因为多个线程之间共享同一个进程的内存,如果没有控制好,就容易出现竞争状态。为了避免这种状态,就出现了互斥锁、信号量等机制。

互斥锁是指一个线程使用某一内存空间时会获得的一把 “锁”,它会使用锁将这个内存空间锁起来,其他线程只有等当前线程使用完毕并将锁释放,才能使用该内存空间中的资源。任何线程获得释放后的锁,就有了使用权利,其他线程依旧要排队等待。

信号量用来指定某一内存空间通常可以被多少个线程使用。例如,当信号量为 4 时,内存空间中最多能同时存在 4 个线程,某个线程操作完内存空间中的资源后,信号量会自增 1,从而允许其他线程进入该内存空间。

下面对进程与线程的相关内容进行简单总结。

  • 允许一个程序,操作系统至少会启动一个进程,而一个进程至少有一个线程。

  • 线程不能独立允许,必须依托于进程。

  • 进程之间是有优先级关系的,而一个进程中的线程是平级的。

  • 不同进程的内存空间相互独立,在操作系统保护模式下,一个进程崩溃不会对其他进程产生影响,而线程没有独立的内存空间,但它有自己的堆栈和局部变量。

  • 线程可以通过共享变量的方式实现多线程直接的通信,而进程的通信主要通过消息传递。相对共享变量而言,消息传递会消耗更多资源。

3. 同步与异步

为了让读者更加深刻地理解 Python 中多线程存在的问题,有必要先了解一下同步与异步的概念。

同步是指不同的程序单元为了完成某个任务通过某种通信方式协调一致,此时可以称这些程序是同步执行的。很多人误以为同步中协调一致的概念表示多个程序单元要同时执行,其实协调一致指的是多个程序单元的执行是有顺序的,先执行哪个程序单元,后执行哪个程序单元,是有明确顺序的,简单而言,同步意味着有序。

对应的异步指不同程序单元之间不需要协调也可单独完成任务。通常,没有先后顺序关联的业务逻辑可以利用异步的方式来实现,如爬虫下载不同的网页、保存等操作可以独立完成,下载程序单元执行无须通信协调,这也就造成了异步是无序的。

Flask 是 Python 中大名鼎鼎的同步式 Web 框架,为什么说它是同步的呢?

使用 Flask 构建一个简单的 API,这个 API 对应的方法是一个耗时方法,比如需要处理 10s 才能获得一个结果。

import time

from flask import Flask

app = Flask(__name__)

def longtime(s):
    time.sleep(s)

@app.route('/index')
def index():
    longtime(10) # 休眠10s,模仿耗时操作
    return 'Hello sync Flask!'

if __name__ == '__main__':
    app.run()

此时,请求 index 这个 API 就需要等待 10s 才能获得结果。这是一个有序的过程,需要先执行 longtime () 方法中的耗时操作,才会继续执行 index () 方法中的逻辑,获得响应结果。因此,Flask 是一款同步框架。

下面利用 ThreadPoolExecutor 将上面的例子改为异步响应的形式。

import time
from concurrent.futures import ThreadPoolExecutor

from flask import Flask

executor = ThreadPoolExecutor(2) # 创建线程池,线程池大小为2

app = Flask(__name__)

def longtime(s):
    time.sleep(s)

@app.route('/index')
def index():
    executor.submit(longtime, 10)  # 交由线程池异步执行耗时任务
    return 'Hello async Flask!'

if __name__ == '__main__':
    app.run()

此时请求 index 这个 API 即可立即获得结果,请求程序单元与耗时任务程序单元两者相互独立,没有先后顺序之分。

4. 并发与并行

同步与异步的概念容易与并发与并行这两个概念混淆,但两者描述的其实是不同级别的事情。对于并发和并行,Erlang 之父 Joe Armstrong 给出的图很好的解释了两者。

图 1

并发指两队人交替使用一台咖啡机,而并行是两队人同时使用两台咖啡机。

并发表示多个程序可以在同一个时间段内被执行,主要用于描述程序的组织结构,程序设计了多个可独立执行的子任务,在同一时间段内,多个任务可以以近实时的形式使用有限的计算机资源,比如在单核 CPU 上,多个任务在同一时间段运行,从效果上看,多个任何似乎在同时运行,但其实并没有实现同时运行,只是单核 CPU 在执行任务时,快速的切换执行不同的任务,让多个任务看上去在同时运行。

并行表示多个程序可以在同一时刻被运行,与并发不同,并行的关键是有物理上的支持,比如双核 CPU,那么同一时刻,不同的 CPU 核上就要运行不同的任务。并发通常用于表示程序的执行状态而不是程序的组织结构,在计算资源充足的情况下,可以轻易达到并行的效果。

目前主要使用的 CPU 为 8 核 CPU,那么同一时刻下,该 CPU 就可以并行执行 8 个任务,而其中的某个 CPU 核可以以并发的形式执行多个任务。

更严谨的描述如下。

  • 1. 并发是说进程 B 的开始时间是在进程 A 的开始时间与结束时间之间,我们就说 A 和 B 是并发的。

  • 2. 并行是并发的真子集,指同一时间两个进程运行在不同的机器上或者同一个机器不同的核心上。

5. 多线程的基本使用

Python 中有 3 种使用线程的方式,分别是 thread、threading 与 ThreadPoolExecutor,Thread 是比较低级别的库,在 Python 3.x 中已经被废弃并重命名为_thread,threading 基于_thread 构建,提供更完全的线程管理能力,而 ThreadPoolExector 在 threading 进一步封装,使得线程的使用进一步简化,但简化带来的问题就是 ThreadPoolExector 无法对创建的线程进行细粒度的控制。

本节主要讨论 Threading 的使用方式。

Threading 有以下两种方式创建线程。

  • 方法一:创建 threading.Thread 实例,将需要被线程执行的函数传入该实例

  • 方法二:创建一个类,该类继承于 threading.Thread,重写其 run () 方法

下面先通过方法一创建线程

import time
import threading

def longtime(n): # 需要被线程执行的函数
    time.sleep(n)

def main():
    # 实例化线程
    t = threading.Thread(target=longtime, args=[10])
    t.start()
    t.join()
    print("Done")

if __name__ == '__main__':
    main()

通过 threading.Thread () 实例化线程对象并将需要线程执行的函数 longtime 传递给 target 参数,longtime 函数对应的参数传递给 args 参数。

其中 start () 用于启动线程,此时线程就开始执行了,如果在同一个线程对象中多次调用会引发 RuntimeError: threads can only be started once 错误,join (timeout=None) 方法会将主线程挂起,直到子线程运行结束,若 timeout 不为 None,则表示主线程最长挂起的时间,主线程结束挂起后,就会继续执行。

但其实更推荐使用方法二以继承的方式来创建线程。

import time
import threading

class MyThread(threading.Thread):
    def __init__(self, func, args, tname=''):
        # 调用父类构造函数
        super(MyThread, self).__init__()
        self.tname = tname
        self.func = func
        self.args = args

    # 线程执行的具体逻辑
    def run(self):
        self.func(*self.args)

def longtime(n):
    time.sleep(n)

def main():
    # 实例化线程
    t = MyThread(longtime, (10,), longtime.__name__)
    t.start()
    t.join()

if __name__ == '__main__':
    main()

通过重写 run () 方法的方式,可以自定义线程具体的执行逻辑,相比方法一,这种方法更加灵活直观。

6. 锁

Threading 库提供了 Lock、RLock、Semaphore、Condition、Event 及 Queue 等多种线程同步机制,下面就来使用一下这些同步机制。

如果没有采用一些同步机制,多个线程使用同一资源时就容易产生意料之外的情况。下面使用两个线程将 1.txt 与 2.txt 这两个文件中的内容按顺序写入同一个文件中。

import time
import threading

class MyThread(threading.Thread):
    def __init__(self, input, output):
        super(MyThread, self).__init__()
        self.input = input
        self.output = output

    def run(self):
        for line in self.input.readlines():
            time.sleep(1) # 模拟耗时操作
            self.output.write(line)
        print('Thread Done')

def main():
    txt1 = open('1.txt', 'r')
    txt2 = open('2.txt', 'r')
    txt3 = open('3.txt', 'a')
    t1 = MyThread(txt1, txt3)
    t2 = MyThread(txt2, txt3)
    t1.start()
    t2.start()
    print('Done')

main()

通过继承的方式构建了 MyThread 类并将线程要执行的逻辑直接写入 run () 中,随后创建了两个线程将不同文件的内容写入同一文件中。此时 3.txt 内容必然是混乱。

要避免这种情况,最简单的方式就是利用锁 Lock 机制,锁使用非常简单。

import time
import threading

class MyThread(threading.Thread):
    def __init__(self, input, output, lock):
        super(MyThread, self).__init__()
        self.input = input
        self.output = output
        self.lock = lock # 传入的lock对象

    def run(self):
        self.lock.acquire() # 获得lock对象,lock状态变为locked,并且阻塞其他线程获取lock对象
        for line in self.input.readlines():
            time.sleep(1) # 模拟耗时操作
            self.output.write(line)
        self.lock.release() # 释放lock对象
        print('Thread Done')

def main():
    lock = threading.Lock() # 创建lock对象
    txt1 = open('1.txt', 'r')
    txt2 = open('2.txt', 'r')
    txt3 = open('3.txt', 'a')
    t1 = MyThread(txt1, txt3, lock)
    t2 = MyThread(txt2, txt3, lock)
    t1.start()
    t2.start()
    print('Done')

main()

threading.Lock () 创建锁对象,通过 acquire () 方法获得锁,通过 release () 释放锁,没有获取锁的线程只能等待。通过这种简单的方式,就避免了 3.txt 内容混乱的问题。此外,可以利用 with 关键字简化锁的获取与释放过程。

def run(self):
        with self.lock: # 锁会自动获取与释放
            for line in self.input.readlines():
                time.sleep(1) # 模拟耗时操作
                self.output.write(line)
        print('Thread Done')

简单总结,线程获取锁后有 locked (被锁) 与 unlocked (未被锁) 两种状态,分别对应 acquire () 与 reelease () 方法,没有获取锁的线程无法执行,通过这种方式可以实现同一时刻下有且只有一个线程在运行的目的。

需要注意,线程在 locked 状态再次调用 acquire () 方法会产生死锁,如果想要在同一个线程下多次使用 acquire () 方法,需要使用可重入锁。

那 Python 内部是怎么实现锁 Lock 的?

在 Lib/threading.py 中可以看到如下代码。

# Lib/threading.py

# Synchronization classes
Lock = _allocate_lock
_allocate_lock = _thread.allocate_lock

通过_threadmodule.c 的 thread_methods 可知 allocate_lock 映射到了 thread_PyThread_allocate_lock () 方法

static PyMethodDef thread_methods[] = {
    ...
    {"allocate_lock",           thread_PyThread_allocate_lock,
     METH_NOARGS, allocate_doc},
    {"allocate",                thread_PyThread_allocate_lock,
     METH_NOARGS, allocate_doc},
     ...

thread_PyThread_allocate_lock () 方法代码如下。

static PyObject *
thread_PyThread_allocate_lock(PyObject *self, PyObject *Py_UNUSED(ignored))
{
    return (PyObject *) newlockobject(); // 创建新的锁对象
}

thread_PyThread_allocate_lock () 方法本质就调用了 newlockobject () 方法获得新的锁对象,该方法代码如下。

static lockobject *
newlockobject(void)
{
    lockobject *self;
    // 更加锁类型创建锁对象
    self = PyObject_New(lockobject, &Locktype);
    if (self == NULL)
        return NULL;
    // 分配锁对象
    self->lock_lock = PyThread_allocate_lock(); 
    self->locked = 0;
    self->in_weakreflist = NULL;
    if (self->lock_lock == NULL) {
        Py_DECREF(self);
        PyErr_SetString(ThreadError, "can't allocate lock");
        return NULL;
    }
    return self;
}

最终的关键就是 PyThread_allocate_lock () 方法,这个方法有两种不同的,通过 USE_SEMAPHORES 常量来判断,如果 USE_SEMAPHORES 存在,则基于信号量机制来实现锁,如果 USE_SEMAPHORES 不存在,则通过互斥量与条件变量来实现锁。

// Python/thread_pthread.h

// 是否直接使用信号量,还是使用互斥量和条件变量模拟信号量
#if (defined(_POSIX_SEMAPHORES) && !defined(HAVE_BROKEN_POSIX_SEMAPHORES) && \
     defined(HAVE_SEM_TIMEDWAIT))
#  define USE_SEMAPHORES
#else
#  undef USE_SEMAPHORES
#endif

具体实现方式虽然不同,但整体思路是一致的,为了简便,只分析基于信号量的实现。PyThread_allocate_lock () 方法基于信号量的实现方式如下。

// Python/thread_pthread.h

// 创建锁对象
PyThread_type_lock
PyThread_allocate_lock(void)
{
    // 以信号量作为锁
    sem_t *lock;
    int status, error = 0;

    dprintf(("PyThread_allocate_lock called\n"));
    if (!initialized)
        PyThread_init_thread();
    // 创建锁对象 - 基于信号量机制的锁对象
    lock = (sem_t *)PyMem_RawMalloc(sizeof(sem_t));

    if (lock) {
        // 初始化锁所在地址的信号量,初始值为1
        status = sem_init(lock,0,1);
        CHECK_STATUS("sem_init");

        if (error) {
            // 失败,释放锁空间
            PyMem_RawFree((void *)lock);
            lock = NULL;
        }
    }

    dprintf(("PyThread_allocate_lock() -> %p\n", (void *)lock));
    // 返回锁对象
    return (PyThread_type_lock)lock;
}

上述代码中,使用 PyMem_RawMalloc () 方法来创建锁对象,并通过 sem_init () 方法来初始化锁所在地址的信号量,初始值为 1,Python 中的锁就是基于操作系统信号量实现的。

要理解 sem_init () 方法需要先理解 POSIX 信号量,POSIX (Protabl Operation System 可移植操作系统规范) 是一种标准,它的意义就是统一不同操作系统提供的方法,方便应用层程序员开发可移植的代码。

目前有两种主流的操作系统,分别是 Windows 系统与类 Unix 系统,其中类 Unix 系统又包括很多,如 Ubuntu、Centos、MacOS 等系统,POSIX 的出现主要统一了类 Unix 系统中的 API,让程序符号 POSIX 标准的程序可以在不同类 Unix 系统中移植。

Windows 系统默认不支持 POSIX 标准,但有相应的开源项目让 Windows 可以支持 POSIX。但 Cpython 底层使用 Windows 系统 API 时,并没有利用这些开源项目,而是直接使用,这就导致 Cpython 的某些部分有多种不同的实现。本章讨论的线程与 GIL 就是系统相关的,在 Cpython 中分别有类 Unix 系统的实现与 Windows 系统的实现。

导入 semaphore.h 头文件,就可以调用 sem_init () 方法初始化无名信号量,此外还可以调用 sem_post () 方法将信号量的值加 1、调用 sem_wait () 方法将信号量的值减 1、调用 sem_destroy () 方法清理信号量,这些方法都会以原子操作执行,即多个线程无法同一时刻对同一信号量进行操作。

进一步看一下 Python 中使用 acquire () 获取锁时,底层发生了什么?

// Python/thread_pthread.h

// 获取锁对象
int
PyThread_acquire_lock(PyThread_type_lock lock, int waitflag)
{
    return PyThread_acquire_lock_timed(lock, waitflag ? -1 : 0, /*intr_flag=*/0);
}

PyLockStatus
PyThread_acquire_lock_timed(PyThread_type_lock lock, PY_TIMEOUT_T microseconds,int intr_flag)
{
    ...
    // 对信号量进行减1操作
    if (microseconds > 0) {
            status = fix_status(sem_timedwait(thelock, &ts));
        }
        else if (microseconds == 0) {
            status = fix_status(sem_trywait(thelock));
        }
        else {
            status = fix_status(sem_wait(thelock));
        }
    ...
}

PyThread_acquire_lock () 方法调用了 PyThread_acquire_lock_timed () 方法,PyThread_acquire_lock_timed () 方法的逻辑比较多,这里只关注关键的部分,可以发现,在 Python 中获取线程其实就是利用了 sem_timedwait ()、sem_trywait () 或 sem_wait () 方法,这些方法都会对信号量减一。

Python 中使用 release () 释放锁的具体逻辑如下。

# Python/thread_pthread.h

// 释放锁对象
void
PyThread_release_lock(PyThread_type_lock lock)
{
    sem_t *thelock = (sem_t *)lock;
    int status, error = 0;

    (void) error; /* silence unused-but-set-variable warning */
    dprintf(("PyThread_release_lock(%p) called\n", lock));
    // 对信号量进行加1操作
    status = sem_post(thelock);
    CHECK_STATUS("sem_post");
}

7. 可重入锁

与普通锁不同,可重入锁底层使用递归实现,同一个线程每一次调用 acquire () 方法获取锁,对应的计数器会加 1,而调用 release () 方法释放锁时,计数器会减 1。可重入锁要求调用 acquire () 方法的次数与调用 release () 方法的次数相同。

可重入锁的使用与普通锁使用除了 lock 对象的实例化外完全相同。

import time
import threading

class MyThread(threading.Thread):
    def __init__(self, input, output, lock):
        super(MyThread, self).__init__()
        self.input = input
        self.output = output
        self.lock = lock # 传入的lock对象

    def run(self):
        self.lock.acquire() # 获得lock对象,lock状态变为locked,并且阻塞其他线程获取lock对象
        self.lock.acquire() # 可重入锁,可以多次使用acquire()
        for line in self.input.readlines():
            time.sleep(1) # 模拟耗时操作
            self.output.write(line)
        self.lock.release() # 释放lock对象
        self.lock.release()
        print('Thread Done')

def main():
    lock = threading.RLock() # 创建lock对象
    txt1 = open('1.txt', 'r')
    txt2 = open('2.txt', 'r')
    txt3 = open('3.txt', 'a')
    t1 = MyThread(txt1, txt3, lock)
    t2 = MyThread(txt2, txt3, lock)
    t1.start()
    t2.start()
    print('Done')

上述代码可以利用 with 关键字进行简化,方式与普通锁 Lock 一样,不再详细分析。

可重入锁的意义就是为了避免同一线程多次获取锁后出现死锁的情况。下面看一下可重入锁实现可重入这种功能时的代码。

# Lib/threading.py

def RLock(*args, **kwargs):
    if _CRLock is None:
        return _PyRLock(*args, **kwargs)
    return _CRLock(*args, **kwargs)
    
_PyRLock = _RLock

# 可重入锁
class _RLock:
    def __init__(self):
        self._block = _allocate_lock() # 锁对象
        self._owner = None # 锁持有者
        self._count = 0 # 计数器
        
    ... 
    
    def acquire(self, blocking=True, timeout=-1):
        me = get_ident()
        # 可重入具体逻辑
        if self._owner == me:
            self._count += 1 # 同一线程调用,计数器加1
            return 1
            
        # 获得锁
        rc = self._block.acquire(blocking, timeout)
        if rc:
            self._owner = me
            self._count = 1
        return rc

    __enter__ = acquire

从代码可以看出,可重入锁的本质就是普通锁 + 计数器,可重入的逻辑非常简单,当同一线程重复调用 acquire () 获取锁时,计数器_count 自增,那 release () 方法中肯定会有计数器自减的操作。

# Lib/threading.py/_RLock

    ... # 省略部分代码
    def release(self):
        if self._owner != get_ident():
            raise RuntimeError("cannot release un-acquired lock")
        self._count = count = self._count - 1 # 计数器减1
        if not count:
            self._owner = None
            self._block.release() # 释放锁

release () 方法先对计数器进行减 1 操作,如果计算器为 0,则释放锁,这也解释了,可重入锁为什么要求 acquire () 方法调用的次数要与 release () 方法一致。

8. 条件锁

条件锁通常用于一个线程需要由另外一个线程发送满足某条件的特定信号后才可执行的情况。

调用 threading.Condition (lock=None) 方法可以轻松创建条件锁,其中 lock 参数必须为普通锁 Lock 或可重入锁 RLock,默认为 RLock。当多个条件变量需要共享一个锁是,传入一个已创建的锁很有用。

这里以生产者 / 消费者模型来解释条件锁的使用,先来构建生成者,它的作用就是随机生成数字并存入全局列表中。

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

    def run(self):
        while True:
            self.condition.acquire() # 获得条件锁
            number = random.randint(0, 100)
            self.numbers.append(number)
            print(f'Producer add {number} in numbers')
            self.condition.notify() # 唤醒消费者线程,不会释放锁
            self.condition.release() # 释放条件锁
            time.sleep(1) # 模拟耗时操作

条件锁同样拥有 acquire () 与 release () 方法,此外,使用了 notify () 方法,该方法会唤醒 wait 状态下的线程,因为 notify () 方法不会释放锁,所以 wait 状态的线程被唤醒后,无法立刻执行,需要等当前线程调用 release () 释放锁。

条件锁依旧可以利用 with 关键字来管理锁的获取与释放,将上述代码简化一下。

def run(self):
        while True:
            with self.condition:
                number = random.randint(0, 100)
                self.numbers.append(number)
                print(f'Producer add {number} in numbers')
                self.condition.notify() # 唤醒消费者线程,不会释放锁
            time.sleep(1) # 模拟耗时操作

消费者线程会获取生产者 append 到全局列表中的数据。

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

    def run(self):
        while True:
            with self.condition:
                while not self.numbers: # 不存在数据
                    self.condition.wait() # 等待,释放系统资源
                number = self.numbers.pop() # 获取数字
                print(f'Consumer pop {number} in numbers')

消费者线程利用 with 关键字来自动化条件锁的获取与释放,此外,当全局列表中没有数据,则调用 wait () 方法,让线程进入 wait (等待) 状态,此时线程会被系统挂起并释放相应的资源,直到被其他线程通过 notify () 或 notify_all () 方法唤醒。

线程调用 wait () 方法会释放锁,进入 wait 状态,当其他线程通过 notify () 方法唤醒 wait 状态下的线程时,线程无法运行,只有当调用了 notify () 方法的线程释放了锁,wait 状态下的线程去竞争获取锁后才可以运行。

将注意力放回到生产者线程对应代码的 run () 中,该方法最后通过 time.sleep (1) 模拟耗时操作,这是有必要的,如果没有休眠 1s 的操作,生产者线程调用了 notify () 唤醒消费者线程后,释放锁又立刻去获取锁,此时消费者线程刚从 wait 状态被唤醒,难以与生产者线程竞争获取锁,从而造成,消费者线程大概率无法获取锁,难以运行。

wait (timeout=None) 其实还可以设置超时时间,过了超时时间,wait 状态下的线程会自动被唤醒。

下面看一下条件锁 Condition 实现时的代码。

# Lib/threading.py/Condition

class Condition:
    def __init__(self, lock=None):
        if lock is None:
            lock = RLock() # 可重入锁
        self._lock = lock
        self.acquire = lock.acquire
        self.release = lock.release
        # 如果lock对象存在相应的方法,则会覆盖Condition类中_release_save()、_acquire_restore()、_is_owned()的默认实现。
        try:
            self._release_save = lock._release_save # 可重入锁
        except AttributeError:
            pass
        try:
            self._acquire_restore = lock._acquire_restore
        except AttributeError:
            pass
        try:
            self._is_owned = lock._is_owned
        except AttributeError:
            pass
        self._waiters = _deque()

如果在初始化时没有指定 lock 参数,那么条件锁内部会构建一个可重入锁 RLock,其 acquire () 与 release () 方法与创建的锁一致 (默认就是调用 RLock 的 acquire () 与 release () 方法),不再详细分析。

需要注意的是 self._release_save 与 self._acquire_restore,如果条件锁使用的是 RLock,就会涉及可重入逻辑,RLock 下的_release_save () 方法会将 RLock 直接释放,不理会计数器中的值,代码如下。

# Lib/threading.py/_RLock

    def _release_save(self):
        if self._count == 0:
            raise RuntimeError("cannot release un-acquired lock")
        count = self._count
        self._count = 0 # RLock计数器直接归零
        owner = self._owner
        self._owner = None
        self._block.release() # 释放RLock锁
        return (count, owner)

而 RLock 下的_acquire_restore () 方法可以将_release_save () 释放后的 RLock 对象复原。

# Lib/threading.py/_RLock

    def _acquire_restore(self, state):
        self._block.acquire()
        self._count, self._owner = state

如果条件锁使用的是普通锁,即通过 threading.Condition (threading.Lock ()) 的方式创建,Condition 类中的_release_save () 方法与_acquire_restore () 方法就不会被覆盖,这些方法在 Condition 类中原始的逻辑如下。

# Lib/threading.py/Condition

    def _release_save(self):
        self._lock.release() # 释放普通锁

    def _acquire_restore(self, x):
        self._lock.acquire() # 获取普通锁

关键看 wait () 方法与 notify () 方法的实现原理,这两个方法是如何实现阻塞线程与唤醒线程操作的?

先看 wait () 方法对应的逻辑,如下。

# Lib/threading.py/Condition

    def wait(self, timeout=None):
        if not self._is_owned():
            raise RuntimeError("cannot wait on un-acquired lock")
        waiter = _allocate_lock() # 创建名为waiter的锁
        waiter.acquire() # 获取waiter锁
        self._waiters.append(waiter)
        saved_state = self._release_save() # 释放基础锁
        gotit = False
        try:
            if timeout is None:
                # 因为waiter锁是普通锁,再次获取waiter锁时,线程会阻塞,等待其他线程释放锁,释放锁的逻辑其实就在notify()方法中,其他线程会调用notify()方法释放锁,从而实现激活的效果。
                waiter.acquire() 
                gotit = True
            else:
                if timeout > 0:
                    # 阻塞,超时后会被动激活
                    gotit = waiter.acquire(True, timeout)
                else:
                    # 不阻塞,直接返回是否获得锁的结果。
                    gotit = waiter.acquire(False)
            return gotit
        finally:
            # 获取基础锁
            self._acquire_restore(saved_state)
            if not gotit:
                try:
                    self._waiters.remove(waiter)
                except ValueError:
                    pass

wait () 方法巧妙的操作两个锁实现暂停线程与等待条件通知后激活线程的效果。

首先,调用 wait () 线程本身必须已经获得了条件锁,即调用了 acquire (),否则抛出异常,其次,在 wait () 方法中,创建了名为 waiter 的普通锁,创建后,立即去获取 waiter 锁,然后调用_release_save () 方法释放线程拥有的条件锁,线程失去条件锁后,会失去运行权限,进入挂起状态。

接着的关键就是再次使用 waiter.acquire () 让 waiter 锁进入阻塞状态,wait () 方法具体的代码会根据 timeout 的不同状态执行不同的逻辑。

如果 timeout 没有设置,就是简单的情况,阻塞当前线程,直到其他线程激活它;如果 timeout>0,情况类似,只是多了超时时间,过了超时时间,被阻塞的线程自动被激活;如果 timeout<=0,此时不会阻塞去等待其他线程释放锁,而是直接将获取锁的情况返回。

在最后的 finally 块下,调用了_acquire_restore () 方法尝试复原线程拥有的条件锁,但此时条件锁通常已经被其他线程获得,当前线程只能阻塞等待。

简单而言,线程调用 wait () 后,进入了双层阻塞状态,一层由 waiter 锁造成,另一层由条件锁造成。

接着看到 notify () 方法的逻辑,如下。

# Lib/threading.py/Condition

    def notify(self, n=1):
        if not self._is_owned():
            raise RuntimeError("cannot notify on un-acquired lock")
        all_waiters = self._waiters
        waiters_to_notify = _deque(_islice(all_waiters, n))
        print(f'waiter from deque, id: {id(waiters_to_notify)}')
        if not waiters_to_notify:
            return
        for waiter in waiters_to_notify:
            waiter.release()
            try:
                all_waiters.remove(waiter)
            except ValueError:
                pass

从 deque 中获得 个 waiter 锁实例对象,然后通过 for 迭代逐个释放并将释放后的 waiter 对象从 all_waiters 中移除。

notify () 中释放 waiter 锁的操作会唤醒调用 wait () 方法后处于阻塞状态的线程,但它只能唤醒 waiter 锁造成的阻塞,条件锁造成的阻塞依旧需要其他线程调用 release () 方法将条件锁释放。

上述内容如有不理解,请回到条件锁的开头,结合通过条件锁实现的生产者 / 消费者模型来理解,动手复现,并尝试修改,观察效果,记住,编程是一门实践性学科。

9. 信号量

信号量机制由早期的荷兰科学家 Edsger W. Dijkstra 发明,提出了 Dijkstra 信号量算法,Python 中的信号量机制其实就是对 Dijkstra 算法的实现。

一个信号量管理着一个内部计数器,线程调用 acquire () 方法时,计数器会减一,反之调用 release () 方法时,计数器会加一。计数器的值永远不会小于 0,当计数器为 0 时,线程调用 acquire () 方法后会进入阻塞状态,直到其他线程调用 release () 方法。

调用 threading.Semaphore (value=1) 方法可以创建信号量,其中 value 表示信号量的个数,默认为 1。

信号量表明看上去很简单,但却有多种用法,创建信号量时,将 value 设置为 0,可以轻松实现生产者 / 消费者模型。

# 创建信号量,初始个数为0
semaphore = threading.Semaphore(0)

# 消费者
def consumer():
    # 信号量计数器减1
    semaphore.acquire()
    print(f'conumser:number is {number}')

def producer():
    global number
    time.sleep(1)
    number = random.randint(0, 100)
    print(f'producer:number is {number}')
    # 信号量计数器加1
    semaphore.release()

if __name__ == '__main__':
    c = threading.Thread(target=consumer)
    p = threading.Thread(target=producer)
    c.start()
    p.start()
    print('Done')

创建信号量时,将 value 设置为 1,则可以实现与普通锁一样的互斥效果。

semaphore = threading.Semaphore()
semaphore.acquire()
... # 具体逻辑
semaphore.release()

除了普通信号量外,调用 threading.BoundedSemaphore (value=1) 可以创建有界信号量,有界信号量常用于保护数量有限的资源,如数据库、服务器等,在使用有界信号量时,不允许计数器的值超过有界信号量的初始值,否则引发 ValueError 异常。

下面以链接服务器为例进行介绍。

# 创建有界信号量,最大值为5,最小值为0
bounded_sem = threading.BoundedSemaphore(value=5)

# 通过with关键字来管理信号量计数器的增减
with bounded_sem:
    conn = connectdb() # 链接数据库
    try:
        ... # 具体操作逻辑
    finally:
        conn.close() # 关闭数据库

信号量在多线程编程中灵活的使用方式可以实现多种效果,但也容易出现死锁的情况,如果只是简单的同步需求,使用普通锁或可重入锁更加,减少代码复杂度。

为了进一步理解 Python 多线程中的信号量机制,依旧来阅读其代码,先看普通信号量的实现机制。

# Lib/threading.py
  
class Semaphore:
    def __init__(self, value=1):
        if value < 0:
            raise ValueError("semaphore initial value must be >= 0")
        self._cond = Condition(Lock()) # 创建条件锁
        self._value = value # 计数器

可以发现,信号量机制基于条件锁实现。

接着阅读信号量 acquire () 方法对应的代码,如下。

# Lib/threading.py/Semaphore

    def acquire(self, blocking=True, timeout=None):
        if not blocking and timeout is not None:
            raise ValueError("can't specify timeout for non-blocking acquire")
        rc = False
        endtime = None
        with self._cond: # 自动化条件锁的获取与释放
            while self._value == 0: # 计数器为0时,判断超时时间,并理解条件锁的wait()方法阻塞线程
                if not blocking:
                    break
                if timeout is not None: # 超时时间
                    if endtime is None:
                        endtime = _time() + timeout
                    else:
                        timeout = endtime - _time()
                        if timeout <= 0:
                            break
                self._cond.wait(timeout) # 阻塞
            else: # 计数器不为0,则计数器减1
                self._value -= 1 # 计数器减1
                rc = True
        return rc

acquire () 方法逻辑简单,利用 with 关键字来自动化条件锁的获取与释放,在 with 块中,先判断计数器的值是否为 0,如果为 0,则调用条件锁的 wait () 方法实现现在的阻塞,如果不为 0,则对计数器的值减 1。

与之对应的 release () 方法代码如下。

# Lib/threading.py/Semaphore

    def release(self):
        with self._cond: # 自动化条件锁的获取与释放
            self._value += 1 # 计数器加1
            self._cond.notify() # 激活调用了wait()被阻塞的线程

除了普通信号量,Python 中还提供了有界信号量 BoundedSemaphore,BoundedSemaphore 的实现方式就是在 Semaphore 类 release () 方法的基础上增加计数器边界值的条件判断,代码如下。

# Lib/threading.py

class BoundedSemaphore(Semaphore):

    def __init__(self, value=1):
        Semaphore.__init__(self, value)
        self._initial_value = value # 边界值

    def release(self):

        with self._cond:
            # 当前计数器的值是否超过边界值
            if self._value >= self._initial_value:
                raise ValueError("Semaphore released too many times")
            self._value += 1
            self._cond.notify()

上述代码比较简单,不再进行分析。

10. 事件通知

事件通知是线程间通信最简单的机制之一,具体而言,一个线程阻塞等待事件信号,另一个线程发送该事件信号。

调用 threading.Event () 可以创建事件对象,事件对象中维护着一个事件标志,其状态默认为 false,通过 3 个方法可以操作该事件标志的状态,实现多线程通信的效果。

  • set () 方法会将事件标志状态设置为 true。

  • clear () 方法将事件标志状态设置为 false

  • wait () 方法会阻塞线程,直到事件标志状态为 true。

当事件标志为 false 时,线程调用 wait () 方法会进入阻塞状态,直到其他线程调用 set () 方法将其设置为 true。

这种通信方式也可以用于实现生产者 / 消费者模型。

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

    def run(self):
        while True:
            number = random.randint(0, 100)
            self.numbers.append(number)
            print(f'Producer add {number} in numbers')
            self.event.set() # 设置事件标志为true,激活其他线程
            time.sleep(1) # 模拟耗时操作

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

    def run(self):
        while True:
            self.event.wait() # 阻塞线程
            while self.numbers: # 不存在数据
                 # 等待,释放系统资源
                number = self.numbers.pop() # 获取数字
                print(f'Consumer pop {number} in numbers')
            self.event.clear() # 设置事件标志为false

def main():
    numbers = []
    event = threading.Event() # 实例化事件对象
    producer = Producer(numbers, event)
    consumer = Consumer(numbers, event)
    producer.start()
    consumer.start()
    producer.join()
    consumer.join()
    print('Done')

生产者线程向全局 numbers 中生成一个数字后,就调用 set () 方法将事件标志设为 true,激活消费者线程。

消费者线程在一开始调用了 wait () 方法,该方法会阻塞线程,直到生产者利用 set () 方法激活它。当消费者处理完 numbers 所有的值后,调用 clear () 方法将事件标志设为 false,当消费者线程进入一轮新循环后,会被阻塞,直到生产者线程生成数字并调用 set () 方法。

依旧来看一下事件通知的内部实现。

# Lib/threading.py

class Event:
    def __init__(self):
        self._cond = Condition(Lock()) # 条件锁
        self._flag = False # 事件标志
        
    ...
    
    # 设置事件标志为true,激活其他线程
    def set(self):
        with self._cond:
            self._flag = True
            self._cond.notify_all()
            
    # 设置事件标志为false  
    def clear(self):
        with self._cond:
            self._flag = False
    
    def wait(self, timeout=None):
        with self._cond:
            signaled = self._flag
            if not signaled:
                signaled = self._cond.wait(timeout) # 利用条件锁的wait()方法阻塞线程
            return signaled

事件通知基于条件锁外加一个_flag 变量实现,逻辑简单,不再详细分析。

11. 栅栏对象

通过 threading.Barrier (parties, action=None, timeout=None) 方法可以创建一个需要 parties 线程的栅栏对象。每个线程调用 wait () 方法进入阻塞状态,直到阻塞线程的数量到达 parties 时,所有阻塞线程会被同时激活。action 参数用于指定可调用的函数,该函数会在所有线程被激活时在其中的一个线程中自动的调用,如果这个函数在执行的过程中出现异常,那么栅栏对象会进入破损状态,而 timeout 则是默认的超时时间,如果超时,栅栏对象也会进入破损状态。

栅栏对象常用于需要进行并发初始化的任务,如运行系统前需要通过多个线程并发进行磁盘文件的价值、数据库连接批量初始化、缓存预热等工作,这些工作可以并发执行,但只有都完成了,系统才能正常进行后续操作。如果并发初始化的过程中,某一部出现问题了,则调用栅栏对象的 abort 方法,将栅栏对象设置为破损状态,这会导致所有调用了 wait () 方法的线程抛出 BrokenBarrierError 异常,直到某个线程调用 reset () 方法来恢复线程。

简单总结一下栅栏对象常用的方法。

  • wait (timeout=None) 方法会阻塞线程,直到满足栅栏对象的 parties,所有调用了 wait () 方法的线程会被激活,冲出栅栏。创建栅栏对象时,如果提供 action 参数,它将在某一个线程被激活前被调用。

  • reset () 方法会重置栅栏为初始状态,但如果有线程调用了 wait () 后还未被激活,调用 reset () 方法后这些未被激活的线程会抛出 BrokenBarrierError 异常。

  • abort () 方法会将栅栏对象设置为破损状态。

栅栏对象简单使用如下。

import threading

b = threading.Barrier(3, timeout=5)

# 初始数据库
def initdb():
    start_db()
    b.wait() # 阻塞
    ... # 后续具体逻辑

# 初始化服务端
def initserver():
    start_server()
    b.wait()
    ... # 后续具体逻辑

# 初始化客户端
def initclient():
    start_client()
    b.wait()
    ... # 后续具体逻辑
    
t1 = threading.Thread(target=initdb)
t2 = threading.Thread(target=initserver)
t3 = threading.Thread(target=initclient)

每个线程负责初始化系统中的一部分,只有当所有的内容初始化完后,才能执行后续期的逻辑。

依旧阅读一下 Barrier 的代码。

# Lib/threading.py

class Barrier:
    def __init__(self, parties, action=None, timeout=None):
        self._cond = Condition(Lock()) # 条件锁
        self._action = action # 某一个线程被激活前被调用的函数
        self._timeout = timeout # 超时时间
        self._parties = parties # 满足线程数
        # 不同的状态
        self._state = 0 #0 filling, 1, draining, -1 resetting, -2 broken
        self._count = 0

从__init__() 方法可以看出,栅栏对象 Barrier 本质依旧是条件锁,其 wait ()、reset ()、abort () 方法都是对条件锁的操作。

看到 wait () 方法代码。

# Lib/threading.py/Barrier

    def wait(self, timeout=None):
        if timeout is None:
            timeout = self._timeout
        with self._cond:
            self._enter() # Block while the barrier drains.
            index = self._count
            self._count += 1
            try:
                # 阻塞线程数等于_parties,释放锁
                if index + 1 == self._parties:
                    # We release the barrier
                    self._release() # 释放所有线程
                else:
                    # We wait until someone releases us
                    self._wait(timeout)
                return index
            finally:
                self._count -= 1
                # Wake up any threads waiting for barrier to drain.
                self._exit()
                
    def _release(self):
        try:
            if self._action:
                self._action()
            # enter draining state
            self._state = 1 # 修改为排水状态
            self._cond.notify_all() # 激活所有线程
        except:
            #an exception during the _action handler.  Break and reraise
            self._break()
            raise

在 wait () 中,利用 with 关键字操作条件锁,然后其计数器进行操作,如果阻塞线程数等于 parites,则释放锁,否则利用条件锁的 wait () 方法将当前现在阻塞。

栅栏对象的其他方法逻辑都类似,其代码不再展示与详细分析。

12. 使用队列同步线程

当线程之间需要共享资源或数据时,队列是个很好的选择,使用 Python 中的 queue 内置库可以轻松获得一个线程安全的队列,队列内部已经实现了锁机制来避免竞争状态。

利用 queue 可以创建多种队列。

  • queue.Queue (maxsize=0) 创建先进先出队列。

  • queue.LifoQueue (maxsize=0) 创建先入后出的队列,效果跟栈相似。

  • queue.PriorityQueue (maxsize=0) 创建优先级队列,优先级队列中的元素通常为一个元组 (priority_number, data),其中 priority_number 是该元素的优先级,data 才是具体的数。据。

队列常用的方法如下。

  • put () 方法向队列中添加数据。

  • get () 方法从队列中获取数据。

  • join () 方法会阻塞线程直到队列中所有元素都被处理完成。

  • task_done () 方法在某一任务完成时被调用。

同样利用队列来实现生产者 / 消费者模型。

from threading import Thread
from queue import Queue
import time
import random

# 生产者
class producer(Thread):
    def __init__(self, queue):
        Thread.__init__(self)
        self.queue = queue

    def run(self) :
        for i in range(10):
            number = random.randint(0, 100)
            self.queue.put(number) # 将数据存入队列
            print(f'producer add {number} to queue')

# 消费者
class consumer(Thread):
    def __init__(self, queue):
        Thread.__init__(self)
        self.queue = queue

    def run(self):
        while True:
            number = self.queue.get() # 获取队列中的数据
            print(f'consumer get {number} from queue')
            self.queue.task_done() # 发送任务完成信息
            time.sleep(1) # 耗时操作

def main():
    queue = Queue() # 创建队列对象
    t1 = producer(queue)
    t2 = consumer(queue)
    t3 = consumer(queue)
    t4 = consumer(queue)
    t1.start()
    t2.start()
    t3.start()
    t4.start()
    
main()

代码逻辑很简单,不再详细分析。

在前面,提及了 queue 内置库可以轻松获得线程安全的队列,这究竟是怎么做到的?阅读代码一探究竟,这里以不同队列 Queue 为例。

# Lib/queue.py

class Queue:
    def __init__(self, maxsize=0):
        self.maxsize = maxsize
        self._init(maxsize) # 创建deque双端队列
        self.mutex = threading.Lock()
        self.not_empty = threading.Condition(self.mutex)
        self.not_full = threading.Condition(self.mutex)
        self.all_tasks_done = threading.Condition(self.mutex)
        self.unfinished_tasks = 0
        
    def _init(self, maxsize):
        self.queue = deque() # 创建双端队列

Queue 普通队列的本质其实是 deque 对象 (双端队列对象),此外还创建了多个锁来避免产生线程冲突的情况,其具体使用的地方可以阅读相应方法的代码。

这里主要关注 put ()、get () 以及 task_done () 这几个前面使用过的方法。

# Lib/queue.py/Queue

    def put(self, item, block=True, timeout=None):
        with self.not_full: # 获取not_full条件锁
            if self.maxsize > 0:
                if not block:
                    if self._qsize() >= self.maxsize: 
                        raise Full 
                elif timeout is None:
                    while self._qsize() >= self.maxsize: # 判断队列大小
                        # 如果队列满了,则阻塞调用put()方法的线程
                        self.not_full.wait() 
                elif timeout < 0:
                    raise ValueError("'timeout' must be a non-negative number")
                else:
                    endtime = time() + timeout
                    while self._qsize() >= self.maxsize:
                        remaining = endtime - time()
                        if remaining <= 0.0:
                            raise Full
                        self.not_full.wait(remaining) # 阻塞线程
            self._put(item) # 添加元素
            self.unfinished_tasks += 1
            self.not_empty.notify() # 队列非空,唤醒调用get()方法的线程
            
    # Put a new item in the queue
    def _put(self, item):
        self.queue.append(item)

put () 方法逻辑简单,先获取 not_full 条件锁,然后判断队列情况,如果队列已满,则阻塞,如果队列没有满,则调用_put () 方法向队列中添加元素并增加未完成任务数 self.unfinished_tasks += 1,然后唤醒调用了 get () 方法的线程。

接着看一下 get () 方法代码

# Lib/queue.py/Queue

    def get(self, block=True, timeout=None):
        with self.not_empty: # 获取not_empty条件锁
            if not block:
                if not self._qsize():
                    raise Empty
            elif timeout is None:
                while not self._qsize(): # 队列为空
                    self.not_empty.wait()
            elif timeout < 0:
                raise ValueError("'timeout' must be a non-negative number")
            else:
                endtime = time() + timeout
                while not self._qsize():
                    remaining = endtime - time()
                    if remaining <= 0.0:
                        raise Empty
                    self.not_empty.wait(remaining)
            item = self._get()
            self.not_full.notify()
            return item
            
    # Get an item from the queue
    def _get(self):
        return self.queue.popleft()

get () 方法中,一开始获取 not_empty 条件锁,如果队列为空,则阻塞该线程,否则就调用_get () 方法从队列中获取元素。

最后看一下 task_done () 方法代码。

# Lib/queue.py/Queue

    def task_done(self):
        with self.all_tasks_done:
            unfinished = self.unfinished_tasks - 1
            if unfinished <= 0:
                if unfinished < 0:
                    raise ValueError('task_done() called too many times')
                self.all_tasks_done.notify_all()
            self.unfinished_tasks = unfinished

task_done () 方法首先会获取 all_tasks_done 条件锁,然后减少未完成任务数,并激活请求 all_tasks_done 条件锁对象的所有线程,all_tasks_done 条件锁其实在 Queue 类的 join () 方法中使用,代码如下。

# Lib/queue.py/Queue

    def join(self):
        with self.all_tasks_done:
            while self.unfinished_tasks:
                self.all_tasks_done.wait()

jion () 方法逻辑简单,先获取 all_tasks_done 条件锁,然后判断未完成任务数量,如果数量大于 0,则阻塞线程,它的作用其实就是为了让队列为空后,再去执行其他逻辑。

13.ThreadLocal

在多线程环境下,每个线程都可以使用所属进程中的全局变量,为了避免发生竞争状态,需要使用锁、可重入锁等同步机制,但全局变量并不能完全满足多线程环境的需求,很多时候还希望线程拥有独立的私有数据,这些数据对当前线程可见,但对其他线程不可见。

似乎让线程使用局部变量就可以实现某些数据对当前线程可见,但对其他线程不可见的效果?

使用局部变量确实可以实现这样的效果,但线程逻辑比较复杂,有较多函数调用时,局部变量的形式并不友好,局部变量作为函数参数,相互传递起来会让代码难以维护。

为了最简单的实现线程拥有独立私有数据,ThreadLocal 应运而生,它的使用方式如下。

import threading

local_v = threading.local() # 全局ThreadLocal

def get_name():
    name = local_v.name # 从ThreadLocal中取出当前线程相应的内容
    print(f'Hello, {name}')

def my_thread(name):
    local_v.name =name # 存入ThreadLocal中
    get_name()

t1 = threading.Thread(target= my_thread, args=('OneOne',), name='Thread-A')
t2 = threading.Thread(target= my_thread, args=('TwoTwo',), name='Thread-B')
t1.start()
t2.start()

全局变量 local_v 就是 ThreadLocal 类型的对象,每个线程都可以操作 local_v 变量的属性且互不影响,其中的数据只对当前线程可见。

依旧来看一下 ThreadLocal 的内部实现。

阅读 threading.py,可以发现 threading.local () 方法有两种实现方式。

# Lib/threading.py

# try:
#     from _thread import _local as local # ThreadLocal C实现
# except ImportError:
#     from _threading_local import local # ThreadLocal Python实现

# 修改成,默认使用使用Python实现的ThreadLocal
from _threading_local import local

默认使用的 C 实现的方式,如果导入失败,则会使用 Python 语言实现的方式,为了方便理解,这里浏览 Python 实现的 ThreadLocal,其具体逻辑在_threading_local.py 文件中。C 实现的 ThreadLocal 与 Python 实现的 ThreadLocal 在逻辑上没有差别。

在_threading_local.py 文件,需要关注 local 类与_localimpl 类,先看到 local 类的__new__() 方法。

# Lib/_threading_local.py

class local:
    __slots__ = '_local__impl', '__dict__'
    
    def __new__(cls, *args, **kw):
        if (args or kw) and (cls.__init__ is object.__init__):
            raise TypeError("Initialization arguments are not supported")
        self = object.__new__(cls)
        impl = _localimpl() # 创建管理当前线程字典的类
        impl.localargs = (args, kw)
        impl.locallock = RLock() # 创建可重入锁
        # 设置属性
        object.__setattr__(self, '_local__impl', impl)
        impl.create_dict()
        return self

在__new__() 方法中,先调用了 object 基类的__new__() 方法实例化当前对象,然后实例化_localimpl 对象,该对象用于管理当前线程的字典,随后赋值了 impl.localargs 属性与 impl.locallock 属性并调用 object.__setattr__方法将 impl 设置给了当前类实例的_local__impl 属性,最后使用 impl.create_dict () 创建当前线程对应的字典。

ThreadLocal 核心逻辑的关键在于_localimpl 类,为了方便理解,将_localimpl 类的代码分多次看。

# Lib/_threading_local.py

class _localimpl:
    """A class managing thread-local dicts 管理局部线程字典的类"""
    __slots__ = 'key', 'dicts', 'localargs', 'locallock', '__weakref__'

    def __init__(self):
        # str(id(self)) 作为 字典的可以, 可以避免冲突
        self.key = '_threading_local._localimpl.' + str(id(self))
        # { id(Thread) -> (ref(Thread), thread-local dict) }
        # 通过id去找到 ref(Thread)[Thread的弱引用]与线程thread-local字典
        self.dicts = {}

    def get_dict(self):
        """Return the dict for the current thread. Raises KeyError if none
        defined."""
        thread = current_thread() # 获得当前线程
        # id(当前线程) -> 获得其字典中存储的值
        return self.dicts[id(thread)][1] # 获得当前线程的局部变量字典

__init__方法中使用当前类对象的内存地址作为 key 的唯一标识,随后创建了一个字典来存储所有线程的私有数据,从注释可以看出其结构,线程的内存地址作为字典的 key,通过这个 key 来获得当前线程的相关数据,包括线程对象的弱引用以及当前线程的私有数据,这些私有数据也是一个字典 (thread-local 字典)。

get_dict () 方法的逻辑比较简单,通过 current_thread () 获得当前线程对象,然后通过 id () 获得这个线程对象的内存地址,以该内存地址为 key 找到对应的数据,取其中第二个元素,即 thread-local 字典,从而获得当前线程的所有的私有数据。

current_thread () 方法代码如下。

# Lib/threading.py

def current_thread():
    try:
        return _active[get_ident()]
    except KeyError:
        return _DummyThread()

关键在于 get_ident () 方法,该方法由 C 语言实现,代码在 Modules/_threadmodule.c 文件中。

// Modules/_threadmodule.c

// 获得当前线程的ID
static PyObject *
thread_get_ident(PyObject *self, PyObject *Py_UNUSED(ignored))
{
    unsigned long ident = PyThread_get_thread_ident();
    if (ident == PYTHREAD_INVALID_THREAD_ID) {
        PyErr_SetString(ThreadError, "no current thread ident");
        return NULL;
    }
    return PyLong_FromUnsignedLong(ident);
}

thread_get_ident () 方法通过 PyThread_get_thread_ident () 方法获得当前线程的 id,该方法代码如下。

// Python/thread_pthread.h

unsigned long
PyThread_get_thread_ident(void)
{
    volatile pthread_t threadid;
    if (!initialized)
        PyThread_init_thread();
    // POSIX 线程由一个 pthread_t 类型的 ID 来引用。
    // 线程可以通过调用 pthread_self () 函数获得自己的线程 ID
    threadid = pthread_self();
    return (unsigned long) threadid;
}

最终通过 pthread_self () 方法获得当前线程的 ID,该方法是 POSIX 多线程中的基本方法,引入 pthread.h 头文件则可使用。

继续阅读_localimpl 类代码,create_dict () 方法用于创建用于存储当前线程数据的字典,该方法代码如下。

# Lib/_threading_local.py/_localimpl

    def create_dict(self):
        """为当成线程创建一个新的字典并将其返回"""
        localdict = {} # 线程局部变量字典
        key = self.key
        thread = current_thread()
        idt = id(thread)
        def local_deleted(_, key=key):
            # 删除 localimpl 后,会删除线程属性。
            thread = wrthread()
            if thread is not None:
                del thread.__dict__[key]
        def thread_deleted(_, idt=idt):
            local = wrlocal()
            if local is not None:
                dct = local.dicts.pop(idt) # 删除对应线程的内容
        # ref()创建弱引用
        wrlocal = ref(self, local_deleted)
        wrthread = ref(thread, thread_deleted)
        thread.__dict__[key] = wrlocal
        # 添加到总的字典中
        self.dicts[idt] = wrthread, localdict
        return localdict

在 create_dict () 方法中,创建了用于存放当前线程私有数据的字典 localdict,然后调用了 ref () 方法创建了 wrlocal 与 wrthread 两个弱引用变量,弱引用的主要通就是减少循环引用从而减少内存中不必要对象存在的数量。一个对象若只被弱引用所引用,则可能在任何时刻被回收。

ref (obj [,callback]) 方法用于创建弱引用,obj 是弱引用的对象,callback 是一个可选的回调函数,当弱引用对象要被 Python 销毁时,callback 参数指定的回调函数会被执行。

这里定义了 local_deleted () 与 thread_deleted () 方法作为弱引用被消除时的回调函数。

最后,将 wrthread 与 localdict 添加到 dicts 这个总字典中,构成 {id (Thread) -> (ref (Thread), thread-local dict) } 结构。

回顾前面的内容,_localimpl 类在 local 类的__new__() 方法中被实例化并调用了 create_dict () 方法创建主线程的 thread-local 字典,那其他子线程是如何创建与使用自身的 thread-local 字典来存储私有数据的呢?

继续看 local 类中其他方法的代码,如下。

# Lib/_threading_local.py/_localimpl

    # 获得当前线程thread-local字典中对应的值
    def __getattribute__(self, name):
        with _patch(self):
            return object.__getattribute__(self, name)

    # 将值设置到当前线程对应的thread-local字典中
    def __setattr__(self, name, value):
        if name == '__dict__':
            raise AttributeError(
                "%r object attribute '__dict__' is read-only"
                % self.__class__.__name__)
        with _patch(self):
            return object.__setattr__(self, name, value)

    # 删除当前线程对应的thread-local字典中对应的值
    def __delattr__(self, name):
        if name == '__dict__':
            raise AttributeError(
                "%r object attribute '__dict__' is read-only"
                % self.__class__.__name__)
        with _patch(self):
            return object.__delattr__(self, name)

上面 3 个方法的代码逻辑类似,都是通过 with 关键字调用_patch () 方法,_patch () 方法会对下面操作加锁,然后分别调用 object 基类对应的__getattribute__、__setattr__、__delattr__方法实现类属性的获取、设置与删除。这 3 个方法分别对应着如下使用方式。

data.name = "My_Thread1" # 调用 __setattr__
print(data.name)    # 调用 __getattribute__
del data.name       # 调用 __delattr__

关键在于_patch () 方法,该方法代码如下。

@contextmanager # 上下文装饰器
def _patch(self):
    impl = object.__getattribute__(self, '_local__impl') # 获得线程字典管理对象
    try:
        dct = impl.get_dict() # 获得当前线程的局部变量字典
    except KeyError:
        # 线程第一次使用时,不存在字典,需要创建
        dct = impl.create_dict()  
        args, kw = impl.localargs
        self.__init__(*args, **kw)
    with impl.locallock: # 操作加锁
        # 保存对象可写属性
        object.__setattr__(self, '__dict__', dct)
        yield

利用 contextmanager 上下文管理器可以快速创建满足 with 关键字用法的方法。_patch () 方法中,一开始会获得线程字典管理对象,通过该对象去获得当前线程的 thread-local 字典,但线程第一次获取是没有字典的,所以会调用 creat_dict () 方法创建,此前在 local 类的__new__() 方法中调用的 create_dict () 方法创建的是主线程赌赢的 thread-local 字典。

获取到字典后,通过 with 关键字来使用 impl.locallock 可重入锁,保证__setattr__() 方法操作的原子性,该方法会将获得的字典设置给当前线程的__dict__属性,从而让当前线程可以通过__getattribute__、__setattr__、__delattr__方法去操作自身的私有数据。

14. 线程存在的问题

讨论了这么 Python 线程相关的内容,但依旧没有回答「在 Python 中为什么无法实现真正的多线程?」这个问题。

首先来看一下 Python 中无法实现真正多线程的具体现象。

在 4 核 CPU 的 MacOS 计算机中运行如下 Python 代码。

from threading import Thread

def loop():
    while True:
        pass

if __name__ == '__main__':

    for i in range(4): # 创建4个线程运行loop()方法
        t = Thread(target=loop)
        t.start()

    while True:
        pass

这段 Python 代码运行时,CPU 消耗如图 2。

图 2

从图 2 中可以看出,Python 程序只占满了 1 核 CPU,其他 3 核 CPU 没有使用到。

为了直观对比,这里使用一下 Java 多线程,看一下 Java 多线程对 CPU 的使用情况,具体代码如下。

// Run.java

public class Run {

    public static void main(String[] args) {

        for (int i = 0; i < 4; i++){
            new MyThread().start();
        }
    }

}

// MyThread.java
public class MyThread extends Thread {
    @Override
    public void run() {
        // super.run();
        // System.out.println("MyThread");
        while (true) {
            
        }
    }
}

java 文件需要编译后才可运行。

javac Run.java
java Run

java 代码运行效果如图 3。

图 3

从图 3 可以看出,Java 多线程会使用 4 核来运行程序,即占满 CPU 中所有的核,图中之所以没有达到 400%,是因为操作系统本身也是程序,需要占用一定的 CPU 资源。

Python 多线程会出现这种现象的原因就是 GIL (Global Interpreter Lock,全局解释器锁),在 CPython 中,每一个 Python 线程执行前都需要去获得 GIL 锁,获得该锁的线程才可以执行,没有获得的只能等待,当具有 GIL 锁的线程运行完成后,其他等待的线程就会去争夺 GIL 锁,这就造成了,在 Python 中使用多线程,但同一时刻下依旧只有一个线程在运行。

需要注意的是,GIL 只存在于通过 C 语言实现的 Python 解释器上,即 CPython 上,后人为了绕过 GIL 的问题利用 Java 开发了 Jpython 或利用 Python 开发了自己的解释器 PyPy,这些上都不存在 GIL 全局解释器锁的问题,但 CPython 才是当前最多人使用的主流 Python 解释器。

看到图 4,图中是 Python 中 GIL 的工作实例,其中有 3 个线程,线程与线程之间是顺序执行的,每个线程开始执行时都会去获得 GIL,防止其他线程线程运行,每执行完一段时间后,就会释放 GIL,让别的线程可以去争夺执行权限,如果自己本身也没有执行完,则本身也会参与这次争夺。

图 4

从图 4 可以看出,Python 中的线程工作一段时间后,会主动释放 GIL,这是为了让其他线程都有机会执行,而释放的时机就涉及到了检查间隔 (check interval) 机制,在早期版本的 Python 中,检查机制是 100 ticks,如图 5,而 Python3.x 后,每 5 毫秒使用一次检查间隔,然后就会释放 GIL 锁。

图 5

因为 GIL 的存在,在 CPU 运算密集型任务下,Python 单线程运行效率会高于 Python 多线程运行效率,Python 多线程要频繁的唤醒线程去争夺 CPU,这个操作本身也会消耗系统资源,但在 IO 密集型任务下,Python 多线程相对于单线程依旧有效率优势。

线程有了 GIL 后并不意味着使用 Python 多线程时不需要考虑线程安全,GIL 的存在是为了方便使用 C 语言编写 CPython 解释器的编写者,而顶层使用 Python 时依旧要考虑线程安全。

15. 详解 GIL

为了支持多线程机制,Python 引入了 GIL,GIL 是解释器级别的互斥锁。在多线程环境下,一个线程拥有解释器访问权限之后,其他线程就只能等待它释放解释器访问权限后才可执行,就算这些线程之间不会涉及公共数据的操作、指令逻辑是相互独立的。

对于单核 CPU 而言,GIL 的影响有限,因为单核 CPU 无法实现并行,但对于多核 CPU,GIL 的存在让 Python 线程变得鸡肋,无论 CPU 有多少核,GIL 锁住了解释器,让解释器只能解释执行一个线程。

既然 GIL 有这样的问题,那是否可以将 GIL 废弃呢?

在 Python 发展的历史中,废弃 GIL 其实被尝试过,在 1999 年的时候,Greg Stein 和 Mark Hammond 基于 Python1.5 尝试构建去除 GIL 的 Python,提供更细粒度的锁,而无需锁住解释器,这样在多核环境下,解释器就可以并行执行多个线程了,但结果令人沮丧,废弃 GIL 转而实现细粒度锁机制会产生大量的加锁、解锁操作,这些操作会消耗系统资源,最终导致去除 GIL 后的 Python 依旧没有什么优势,而且在单线程环境测试,去除 GIL 后的 Python 因为依赖与更多细粒度的锁,其运行效率只有原版 Python 效率的一半左右。除此之外,失去 GIL 保护后,Cpython 解释器编写以及其他模块编写变得困难,要时刻注意是否产生线程安全问题。所以,到 Python3.8 为止,GIL 依旧存在,如果想要发挥多核 CPU 的优势,需要使用多进程来编写程序。

虽然 GIL 依旧存在,但 GIL 本身也一直被优化,在 Python3.2 中对 GIL 进行重要的改进,改进后的 GIL 相比于 Python2.x 中旧版 GIL 会让线程对 GIL 的竞争更加平稳,效率会更高,如图 6 所示,出自 David Beazley。

图 6

造成图 6 现象的原因是,旧 GIL 基于 ticker (ticker 默认为 100) 来决定是否释放 GIL,释放完 GIL 后,释放的线程依旧会参与 GIL 争夺,这就使得某线程一释放 GIL 就立刻去获得它,而其他 CPU 核下的线程相当于白白被唤醒,没有抢到 GIL 后,继续挂起等待,造成系统资源浪费,形象如图 7。

图 7

写一段简单的代码测试一下 Python2.7 上旧的 GIL 与 Python3.8 上新的 GIL 运行效率的差别,运行环境为 4 核 2.5GHz Intel Core i7 MacOS Pro 10.13.6。

import time
from threading import Thread

def count(n):
    while n > 0:
        n -= 1

start = time.time()
count(100000000)
count(100000000)
print(f'顺序执行:{time.time() - start}')

start = time.time()
t1 = Thread(target=count,args=(100000000,))
t2 = Thread(target=count,args=(100000000,))
t1.start()
t2.start()
t1.join()
t2.join()
print(f'多线程执行:{time.time() - start}')

上述代码在 Python2.7 下运行需要简单修改,此外,代码中使用 time.time () 计算的是程序的运行时间,该方法会受到机器的影响,除了 windows 外,该方法在其他的平台精度比较高,如果你使用 windows,可以使用 time.clock () 计算的是 CPU 的时间,在 windows 平台上精度比较高。

上述代码多次运行,输出结果如下。

# Python2.7运行结果
顺序执行:26.518435955047607
多线程执行:38.5986328125

# Python3.8运行结果
顺序执行:9.799257040023804
多线程执行:10.256993055343628

从结果可以看出,Python2.7 下多线程执行耗时几乎是顺序执行的 1.5 倍,而 Python3.8 下,两者差异微小。

Python2.7 下顺序执行与多线程执行存在这么大的差距,就是因为旧 GIL 本身的设计存在问题,在多线程环境中消耗了大量系统资源去争夺 GIL。

改进后的 GIL 不再使用 ticker,而改为使用时间,可以通过 sys.getswitchinterval () 来查看 GIL 释放的时间,默认为 5 毫秒,此外虽然说新 GIL 使用了时间,但决定线程是否释放 GIL 并不取决于时间,而是取决于 gil_drop_request 这一全局变量,如果 gil_drop_request=0,则线程会在解释器中一直运行,直到 gil_drop_request=1,此时线程才会释放 GIL。

下面通过两个线程来解释新 GIL 在其中发挥的具体作用。

首先存在两个线程,Thread 1 是正在运行的状态,Thread 2 是挂起状态,如图 8。

图 8

Thread 2 之所以挂起,是因为 Thread 2 没有获得 GIL,它会执行 cv_wait (gil,TIMEOUT) 定时等待方法,等待一段时间 (默认 5 毫秒),直到 Thread 1 主动释放 GIL,比如 Thread 1 执行 I/O 操作时会进入休眠状态,此时它会主动释放 GIL,如图 9。

图 9

当 Thread 2 收到 signal 信号后,就知道 Thread 1 要休眠了,此时它就可以去获取 GIL 从而执行自身的逻辑。

另外一种情况就是,Thread 1 一直在执行,执行的时间超过了 Thread 2 cv_wait (gil,TIMEOUT) 方法等待的时间,此时 Thread 2 就会去修改全局变量 gil_drop_request,将其设置为 1,然后自己再次调用 cv_wait (gil,TIMEOUT) 挂起等待,如图 10。

图 10

Thread 1 发现 gil_drop_request=1 会主动释放 GIL,并通过 signal 通知 Thread 2,让其获取 GIL 去运行,如图 11。

图 11

其中需要注意的细节如下图。当 Thread 1 因为 gil_drop_request=1 要主动释放 GIL 后,会调用 cv_wait (gotgil) 方法进入等待状态,该状态下的 Thread 1 会等待 Thread 2 返回的 signal 信号,从而得知另一个线程 (Thread 2) 成功获得了 GIL 并在执行状态,这就避免了多个线程争夺 GIL 的情况,从而避免了额外资源的消耗,如图 12。

图 12

然后相同的过程会重复的发生,直到线程执行结束,如图 13。

图 13

如果存在多个线程 (大于 2 个线程),此时多个线程出现等待时间超时,此时会不会发生多个线程争夺 GIL 的情况呢?答案是不会,如图 14:

图 14

当 Thread 1 执行时,Thread 2 等待超时了,会设置 gil_drop_request = 1,从而让 Thread 2 获得运行权限,如果此时 Thread 3 或 Thread 4 一会后也超时了,此时是不会让 Thread 2 将获得的 GIL 立即释放的,Thread 3/4 会继续在挂起状态等待一段时间。

还需要注意的一点是,设置 gil_drop_request=1 的线程并不一定会是下一个要执行的线程,下一个要执行那个线程,这取决于操作系统,直观理解如图 15:

图 15

图 15 中,Thread 2 到了超时时间,将 gil_drop_request 设置为了 1,但 Thread 1 发送 signal 信号的线程是 Thread 3,这造成 Thread 2 继续挂起等待,而 Thread 3 获得 GIL 执行自身逻辑。

改进后的 GIL 其顺序执行时间与多线程运行时间不会有太大差距。

16. 简析线程源码

通过前面的内容,已经比较全面的了解 Python 线程与 GIL 了,但依旧不知道线程与 GIL 是怎么实现的,本节就来剖析 Python 线程与 GIL 相关的源码,讨论一下线程与 GIL 的实现细节。

使用线程最基本的用法如下。

t1 = threading.Thread(target= my_thread)
t1.start()

通过 Thread 类创建线程对象,然后通过 start () 方法启动线程,Thread 类的__init__() 方法主要线程对象的各种属性,比较简单,不详细分析,将注意力放到 start () 方法,其代码如下。

# Lib/threading.py

def start(self):
   if not self._initialized:
       raise RuntimeError("thread.__init__() not called")

   if self._started.is_set():
       raise RuntimeError("threads can only be started once")
   with _active_limbo_lock:
       _limbo[self] = self
   try:
        # 启动线程
       _start_new_thread(self._bootstrap, ()) 
   except Exception:
       with _active_limbo_lock:
           del _limbo[self]
       raise
   self._started.wait()

start () 方法将_bootstrap 结构作为参数传递给_start_new_thread () 方法,通过该方法启动线程,该方法其实就是_thread.start_new_thread,为了进一步谈及其逻辑,就需要浏览相应的 C 源码。

Python 中的线程其实依托于操作系统的线程,不同的操作系统,其创建、使用线程的方式并不相同,Cpython 的代码中通过_POSIX_THREADS 常量与 NT_THREADS 常量来做区分。

// Python/thread.c

// python thread在不同系统中使用不同的系统线程实现。
// 类Unix系统
#if defined(_POSIX_THREADS)
#   define PYTHREAD_NAME "pthread"
#   include "thread_pthread.h" // 类Unix平台相关
// Windows
#elif defined(NT_THREADS)
#   define PYTHREAD_NAME "nt"
#   include "thread_nt.h" // windows平台相关
#else
#   error "Require native threads. See https://bugs.python.org/issue31370"
#endif

这里依旧以类 Unix 系统相关源码为主要分析对象。

通过 thread_methods 相关定义,可知_thread.start_new_thread 方法在 C 中对应的方法为 thread_PyThread_start_new_thread ()。

// Modules/_threadmodule.c

static PyMethodDef thread_methods[] = {
    {"start_new_thread", (PyCFunction)thread_PyThread_start_new_thread,
     METH_VARARGS, start_new_doc},
    {"start_new", (PyCFunction)thread_PyThread_start_new_thread,
     METH_VARARGS, start_new_doc},
    ...

thread_PyThread_start_new_thread () 方法代码如下。

static PyObject *
thread_PyThread_start_new_thread(PyObject *self, PyObject *fargs)
{
    PyObject *func, *args, *keyw = NULL;
    struct bootstate *boot;
    unsigned long ident;
    
    // ... 省略部分代码
    
    // 创建并初始化boostate结构boot,bootstate结构会保存关于线程的一切信息
    boot = PyMem_NEW(struct bootstate, 1);
    if (boot == NULL)
        return PyErr_NoMemory();
    boot->interp = _PyInterpreterState_Get();
    boot->func = func;
    boot->args = args;
    boot->keyw = keyw;
    boot->tstate = _PyThreadState_Prealloc(boot->interp);
    if (boot->tstate == NULL) {
        PyMem_DEL(boot);
        return PyErr_NoMemory();
    }
    Py_INCREF(func);
    Py_INCREF(args);
    Py_XINCREF(keyw);
    // 初始化多线程环境,解释器默认不初始化,只有用户使用时,才初始化。
    PyEval_InitThreads(); /* Start the interpreter's thread-awareness */
    // 创建线程
    ident = PyThread_start_new_thread(t_bootstrap, (void*) boot);
    if (ident == PYTHREAD_INVALID_THREAD_ID) {
        PyErr_SetString(ThreadError, "can't start new thread");
        Py_DECREF(func);
        Py_DECREF(args);
        Py_XDECREF(keyw);
        PyThreadState_Clear(boot->tstate);
        PyMem_DEL(boot);
        return NULL;
    }
    // 返回线程id
    return PyLong_FromUnsignedLong(ident);
}

thread_PyThread_start_new_thread () 方法主要做 3 个重要的动作。

  • 1. 创建并初始化 bootstate 结构 boot 并将关于线程一切信息都存入该结构中

  • 2. 通过 PyEval_InitThreads () 方法初始化 Python 多线程环境

  • 3. 通过 PyThread_start_new_thread () 方法创建操作系统的原生线程,传入参数为 t_bootstrap () 方法与 bootstate 结构

关于第 2 个动作,简单解释一下,当 Python 启动时,解释器默认不初始化多线程环境,多线程需要的数据结构以及 GIL 都没有创建。

大多数简单的 Python 程序默认不使用多线程,如果默认激活多线程环境,那么默认每 5 毫秒解释器就会执行一次多线程调度逻辑,进行 GIL 的争夺,对于无需使用多线程的代码,这是额外的无用消耗,所以只有当用户主动使用多线程时,才会通过初始化多线程环境。

PyEval_InitThreads () 方法代码如下。

// Python/ceval.c

void
PyEval_InitThreads(void)
{
    _PyRuntimeState *runtime = &_PyRuntime;
    struct _ceval_runtime_state *ceval = &runtime->ceval;
    struct _gil_runtime_state *gil = &ceval->gil;
    // gil_created()判断gil是否被创建过,避免重复创建
    if (gil_created(gil)) {
        return;
    }
    // 判断Py_DEBUG,创建了一些变量
    PyThread_init_thread();
    // 创建gil
    create_gil(gil);
    PyThreadState *tstate = _PyRuntimeState_GetThreadState(runtime);
    // 持有gil
    take_gil(ceval, tstate);

    struct _pending_calls *pending = &ceval->pending;
    pending->lock = PyThread_allocate_lock();
    if (pending->lock == NULL) {
        Py_FatalError("Can't initialize threads for pending calls");
    }
}

为了避免 GIL 被重复创建,先通过 gil_created () 判断 GIL 是否已经存在,如果 GIL 存在了,说明多线程环境此前已经初始化过了,不再重复进行初始化流程,直接 return。

如果 GIL 不存在,则继续多线程环境初始化流程。PyThread_init_thread () 方法只是简单的判断 Py_DEBUG 并依据不同结果创建不同变量,接着通过 create_gil () 方法创建 GIL 并通过 take_gil () 方法持有 GIL,最终通过 PyThread_allocate_lock () 方法分配锁。

分别看一下 create_gil ()、take_gil () 与 PyThread_allocate_lock () 方法的代码。

create_gil () 方法代码如下。

// Python/ceval_gil.h

// 创建GIL
static void create_gil(struct _gil_runtime_state *gil)
{
    // 创建默认的互斥锁 pthread_mutex_init(&gil_mutex, NULL)
    MUTEX_INIT(gil->mutex);
#ifdef FORCE_SWITCHING
    MUTEX_INIT(gil->switch_mutex);
#endif
    // 初始化条件变量 pthread_cond_init(&gil_cond, NULL)
    COND_INIT(gil->cond);
#ifdef FORCE_SWITCHING
    COND_INIT(gil->switch_cond);
#endif
    _Py_atomic_store_relaxed(&gil->last_holder, 0);
    _Py_ANNOTATE_RWLOCK_CREATE(&gil->locked);
    _Py_atomic_store_explicit(&gil->locked, 0, _Py_memory_order_release);
}

#define MUTEX_INIT(mut) \
    if (PyMUTEX_INIT(&(mut))) { \
        Py_FatalError("PyMUTEX_INIT(" #mut ") failed"); };
        
#define COND_INIT(cond) \
    if (PyCOND_INIT(&(cond))) { \
        Py_FatalError("PyCOND_INIT(" #cond ") failed"); };
        

//  Python/condvar.h
#define PyMUTEX_INIT(mut)       pthread_mutex_init((mut), NULL)

#define PyCOND_INIT(cond)       _PyThread_cond_init(cond)

// Python/thread_pthread.h
int
_PyThread_cond_init(PyCOND_T *cond)
{
    return pthread_cond_init(cond, condattr_monotonic);
}

create_gil () 方法里调用 pthread_mutex_init () 方法与 pthread_cond_init () 方法 (两个方法为 pthread.h 中的方法) 完成了互斥锁的创建与条件变量的初始化,并通过后面的逻辑改变了 & gil->locked 中的值,从而完成 GIL 的创建。

接着看一下 take_gil () 方法,其代码如下。

// Python/ceval_gil.h

static void
take_gil(struct _ceval_runtime_state *ceval, PyThreadState *tstate)
{
    // ... 省略
    // 加锁
    MUTEX_LOCK(gil->mutex);

    if (!_Py_atomic_load_relaxed(&gil->locked)) {
        // 如果GIL已经释放,直接获取并跳转到_ready
        goto _ready;
    }

    // GIL未释放
    while (_Py_atomic_load_relaxed(&gil->locked)) {
        int timed_out = 0;
        unsigned long saved_switchnum;
        // 记录切换次数
        saved_switchnum = gil->switch_number;
        unsigned long interval = (gil->interval >= 1 ? gil->interval : 1);
        // 超时等待,COND_TIMED_WAIT()在Linux平台,最终会调用pthread_cond_timedwait()方法实现超时等待。
        COND_TIMED_WAIT(gil->cond, gil->mutex, interval, timed_out);

        // 等待超时,GIL仍未释放,发送释放请求信号
        if (timed_out &&
            _Py_atomic_load_relaxed(&gil->locked) &&
            gil->switch_number == saved_switchnum)
        {
            // 设置 gil_drop_request=1,eval_breaker=1
            SET_GIL_DROP_REQUEST(ceval);
        }
    }

从 take_gil () 方法的代码可以看出,整个申请 GIL 释放的逻辑都被 gil->mutex 互斥锁锁住。

如果 GIL 被占用,则进入等待状态,通过 COND_TIMED_WAIT () 方法进行等待,该方法在 Linux 平台下,本质是调用 pthread_cond_timedwait () 方法实现超时等待,如果等待超时,则调用 SET_GIL_DROP_REQUEST () 方法将 gil_drop_request、eval_breaker 这两个变量置 1。

// Python/ceval.c

#define SET_GIL_DROP_REQUEST(ceval) \
    do { \
        _Py_atomic_store_relaxed(&(ceval)->gil_drop_request, 1); \
        _Py_atomic_store_relaxed(&(ceval)->eval_breaker, 1); \
    } while (0)

如果 GIL 没有被占用,则 goto 跳转到_ready 处,代码如下。

// Python/ceval_gil.h

_ready:
// ... 省略部分代码

    if (_Py_atomic_load_relaxed(&ceval->gil_drop_request)) {
        // 重置 gil_drop_request=0
        RESET_GIL_DROP_REQUEST(ceval);
    }
    if (tstate->async_exc != NULL) {
        _PyEval_SignalAsyncExc(ceval);
    }
    // 释放GIL
    MUTEX_UNLOCK(gil->mutex);
    errno = err;
}

_ready 处的代码简单而言,竞争获取到 GIL 后,重置 gil_drop_request。

最后一个就是 PyThread_allocate_lock () 方法,该方法在 5.1.5 小结已经比较详细的介绍,不再详细分析。

至此,用于初始化多线程环境的 PyEval_InitThreads () 方法就分析完了,接着看 PyThread_start_new_thread () 方法,代码如下。

// Python/thread_pthread.h

unsigned long
PyThread_start_new_thread(void (*func)(void *), void *arg)
{
    pthread_t th; // 创建线程对象

    // ... 省略部分代码
    
    // 创建线程 (POSIX)
    status = pthread_create(&th,
#if defined(THREAD_STACK_SIZE) || defined(PTHREAD_SYSTEM_SCHED_SUPPORTED)
                             &attrs,
#else
                             (pthread_attr_t*)NULL,
#endif
                             pythread_wrapper, callback);

    // ... 省略部分代码

    pthread_detach(th); // 将子线程转为detached非阻塞状态

#if SIZEOF_PTHREAD_T <= SIZEOF_LONG
    return (unsigned long) th; // 线程 id 存放在 &th。
#else
    return (unsigned long) *(unsigned long *) &th;
#endif
}


typedef struct {
    void (*func) (void *);
    void *arg;
} pythread_callback;

static void *
pythread_wrapper(void *arg)
{
    pythread_callback *callback = arg;
    void (*func)(void *) = callback->func; // t_bootstrap()方法
    void *func_arg = callback->arg;
    PyMem_RawFree(arg);

    func(func_arg);
    return NULL;
}

PyThread_start_new_thread () 方法代码比较长,文中只展示了需关心的代码。该方法调用 phread 库中的 pthread_create () 方法创建新的线程,并调用 pthread_detach () 方法将其创建线程的状态设置为非阻塞状态,最后返回线程 id。

调用 pthread_create () 方法时,传入了 pythread_wrapper 方法,该方法包裹着 t_bootstrap () 方法以及该方法相关的参数 arg,这个参数就是 boostate 结构体。

struct bootstate {
    PyInterpreterState *interp;
    PyObject *func;
    PyObject *args;
    PyObject *keyw;
    PyThreadState *tstate;
};

当 pthread_create () 创建的线程开始执行时,其实就是执行 t_bootstrap (bootstate)。

在进一步分析 t_bootstrap () 方法前,解释一下 pthread_detach () 方法的意义。利用 pthread 创建一个线程,它默认会处于 joinable 状态,如果一个线程运行结束却没有被 join,此时就会进入 “假死” 状态,即线程运行完后,一部分资源没有被回收,线程依旧存在没有被完全销毁,而 joinable 状态下的线程可以避免这种情况,但 joinable 状态的线程会阻塞调用者,无法实现异步的效果,为了避免阻塞,此时就可以调用 pthread_datach () 方法将线程设置成 detached 状态,此时线程不会阻塞调用者并且在运行结束后自动释放所有资源。

t_bootstrap () 方法代码如下。

// Modules/_threadmodule.c 

static void
t_bootstrap(void *boot_raw)
{
    struct bootstate *boot = (struct bootstate *) boot_raw;
    PyThreadState *tstate;
    PyObject *res;

    tstate = boot->tstate;
    // 获得当前线程的id
    tstate->thread_id = PyThread_get_thread_ident(); 
    _PyThreadState_Init(&_PyRuntime, tstate);
    // 竞争GIL
    PyEval_AcquireThread(tstate);
    tstate->interp->num_threads++; // 线程数加一
    // 执行相应的用户代码
    res = PyObject_Call(boot->func, boot->args, boot->keyw);
    // ... 省略部分代码
    PyMem_DEL(boot_raw); // 销毁线程
    tstate->interp->num_threads--; // 线程数减一
    // 清理线程相关资源
    PyThreadState_Clear(tstate);
    PyThreadState_DeleteCurrent();
    PyThread_exit_thread();
}

t_bootstrap () 方法的关键部分已经标了详细的注释,竞争获得 GIL 后,通过 PyObject_Call () 方法执行相应的用户代码,竞争使用了 PyEval_AcquireThread () 方法,该方法其实就是调用 take_gil () 方法去获取 gil。

整体子线程的生命周期都在 t_bootstrap () 方法中,PyObject_Call () 方法一旦之下完成,就会执行线程资源清理的逻辑,清理方法部分代码如下。

// Python/pystate.c

void
PyThreadState_Clear(PyThreadState *tstate)
{
    // ... 省略部分代码
    Py_CLEAR(tstate->frame);

    Py_CLEAR(tstate->dict); // 清理对应的变量
    Py_CLEAR(tstate->async_exc);
    // ... 省略部分代码


// Python/pystate.c

void
PyThreadState_Delete(PyThreadState *tstate)
{
    _PyThreadState_Delete(&_PyRuntime, tstate);
}


static void
_PyThreadState_DeleteCurrent(_PyRuntimeState *runtime)
{
    struct _gilstate_runtime_state *gilstate = &runtime->gilstate;
    PyThreadState *tstate = _PyRuntimeGILState_GetThreadState(gilstate);
    // ... 省略部分代码
    // 调整ThreadState链表,删除当前tstate,释放内存
    tstate_delete_common(runtime, tstate); 
    // ... 省略部分代码
    PyEval_ReleaseLock(); // 调用drop_gil()方法释放GIL
}

// Python/ceval.c

void
PyEval_ReleaseLock(void)
{
    _PyRuntimeState *runtime = &_PyRuntime;
    PyThreadState *tstate = _PyRuntimeState_GetThreadState(runtime);
    drop_gil(&runtime->ceval, tstate); // 释放GIL
}


// Python/thread_pthread.h

void _Py_NO_RETURN
PyThread_exit_thread(void)
{
    dprintf(("PyThread_exit_thread called\n"));
    if (!initialized)
        exit(0);
    pthread_exit(0); // 退出线程
}

清理线程相关代码中有详细的注释,不再详细分析。

  • 15
    点赞
  • 81
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 7
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

懒编程-二两

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值