Python GIL

Table of Contents

1. Python GIL

1.1. python 多线程并没有利用多核?

1.1.1. python threading

import threading
import time

start = time.time()
def thread_demo(n):
    for i in range(n):
        i += 1

N = 10000000
thread_demo(N)
print(f"time: {time.time()-start}")

start = time.time()
t1 = threading.Thread(target=thread_demo, args=(N // 2,))
t2 = threading.Thread(target=thread_demo, args=(N // 2,))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"time: {time.time()-start}")

time: 0.9939150810241699 time: 0.9706227779388428

使用两个线程在多核的机器上并没有缩短运行时间

1.1.2. c pthread

// 2021-03-11 14:27
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
void thread_demo() {
    int total = 0;
    for (int i = 0; i < 10000000; i++) {
        total += i;
    }
    printf("%d\n", total);
}

int main(int argc, char *argv[]) {
    pthread_t tid;
    pthread_t tid2;
    pthread_t tid3;
    void *res;
    pthread_create(&tid, NULL, thread_demo, NULL);
    pthread_create(&tid2, NULL, thread_demo, NULL);
    pthread_join(tid, &res);
    pthread_join(tid2, &res);
    return 0;
}
$> gcc test.c -lpthread -O0
$> time ./a.out
-2014260032
-2014260032

real    0m0.062s
user    0m0.123s
sys     0m0.000s

user 时间代表运行在 userspace 的总时间, real 代表 wall time, 由于利用了双核来运行, 所以 user 是 real 的两倍.

所以使用多线程可以使代码在多核机器上运行时间 (real) 缩短一倍

1.2. GIL

python 的 multithreading 包使用的实际上是系统线程 (而不是 green thread), 但为了保护 python intepreter 自己的一些资源, 它使用 GIL (Global Interpreter Lock) 来阻止多线程在多核上同时通过解释器来执行 byte code

GIL 只是保护 intepreter 内部实现的一些数据, 例如 python object 的引用计数. 用户自己的共享数据还是需要自己去保护: 虽然解释器无法多线程同时执行 byte code, 但对全局变量的访问并非原子操作, 多线程交替执行时还是会有问题

import dis

dis.dis(compile("x+=1", "", "exec"))

1 0 LOAD_NAME 0 (x) 2 LOAD_CONST 0 (1) 4 INPLACE_ADD 6 STORE_NAME 0 (x) 8 LOAD_CONST 1 (None) 10 RETURN_VALUE

import threading
import time

counter = 0
start = time.time()


def thread_demo():
    global counter
    for _ in range(1000000):
        counter += 1


t1 = threading.Thread(target=thread_demo)
t2 = threading.Thread(target=thread_demo)
t1.start()
t2.start()
t1.join()
t2.join()
print(counter)

1334693

1.3. 结论

GIL 导致 threading 无法利用多核执行 byte code 且并不保护用户数据, 如何解决这个问题:

  1. 换其它的解释器, 比如 Jython
  2. 换成 multiprocessing 包, 利用多进程去处理
  3. 自己把多线程代码用 native 改写, 利用 native 的多线程去处理
  4. 代码本身主要执行 IO, 图像处理, numpy 运算, native 操作等. 执行这些时不需要 python 解释器, 也就不需要 GIL

1.4. case study

1.4.1. 问题

线程 A 用 swig 封装了一个 video capture 功能, 不断的读取 image 并通过 zmq publish 出去, 线程 B 通过 sub 来读取 image 并处理.

测试时发现, 线程 A 不断的 pub, 但线程 B 读取到 image 的速度却很慢. 但线程 A 使用另一个 opencv 自带的 video capture 时, 则没有问题: 线程 B 会很快的读到 image

1.4.2. 解决步骤

1.4.2.1. 用 py-spy 进行 profiling
  1. 使用 opencv 进行 capture

    sudo py-spy record --pid 348301 -t -o /tmp/test.svg
    

    gil_webcam.png

看可以看 thread 317 调用 video_capture 占用了一部分 CPU

  1. 使用 opencv, profile 时只显示占用 GIL 的线程

    sudo py-spy record --pid 355982 -t -o /tmp/test.svg --gil
    

    gil_webcam_2.png

可以看到 video_capture 线程没有显示了, 表示它并没有占用 GIL

  1. 使用 swig, 只显示 GIL

    gil_swig.png

    video_capture 占用了很多的 GIL

1.4.2.2. swig 为什么占用 GIL

swig 生成的 pub 线程占用了大量的 GIL, 导致 sub 线程无法在另一个核上运行. swig 生成的代码为什么会占用 GIL?

通过查看 swig 生成的 wrap.cxx 可以看到类似下面的代码:

#if defined(SWIG_PYTHON_THREADS) /* Threading support is enabled */
#  define SWIG_PYTHON_THREAD_BEGIN_BLOCK   PyGILState_STATE _swig_thread_block = PyGILState_Ensure()
#  define SWIG_PYTHON_THREAD_END_BLOCK     PyGILState_Release(_swig_thread_block)
#  define SWIG_PYTHON_THREAD_BEGIN_ALLOW   PyThreadState *_swig_thread_allow = PyEval_SaveThread()
#  define SWIG_PYTHON_THREAD_END_ALLOW     PyEval_RestoreThread(_swig_thread_allow)
#else /* No thread support */
#  define SWIG_PYTHON_INITIALIZE_THREADS
#  define SWIG_PYTHON_THREAD_BEGIN_BLOCK
#  define SWIG_PYTHON_THREAD_END_BLOCK
#  define SWIG_PYTHON_THREAD_BEGIN_ALLOW
#  define SWIG_PYTHON_THREAD_END_ALLOW
#endif

SwigPtr_PyObject(const SwigPtr_PyObject& item) : _obj(item._obj)
{
    SWIG_PYTHON_THREAD_BEGIN_BLOCK;
    Py_XINCREF(_obj);      
    SWIG_PYTHON_THREAD_END_BLOCK;
}

所以是否需要持有 GIL 是要通过 PYGIL… 之类的函数显式的告诉 python intepreter 的.

当声明了 SWIG_PYTHON_THREADS 时, swig 会在调用 native 函数前后加上 GIL 相关的代码

1.4.2.3. 解决方案

http://www.swig.org/Doc4.0/Python.html

通过 swig 命令行上加上 `-threads` 参数就可以保证 swig 调用到 native 代码时不会持有 GIL 了.

再测试一下:

gil_swig_2.png

可以看到 video_capture 已经不占用 GIL 了

ps. 我是怎么找到可能和 GIL 相关的?

一开始我想调整一下 python 线程的优先级看看有没有效果, 结果发现两个信息:

  1. python 的 threading 没有相应的 api
  2. 由于 GIL 的存在, python 线程的性能和优先级关系不大

Author: [email protected]
Date: 2021-03-11 Thu 00:00
Last updated: 2022-01-24 Mon 19:34

知识共享许可协议