GIL

线程全局锁(Global Interpreter Lock),即Python为了保证线程安全而采取的独立线程运行的限制,说白了就是一个核只能在同一时间运行一个线程.
static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */
这一行代码摘自 ceval.c —— CPython 2.7 解释器的源代码,Guido van Rossum 的注释”This is the GIL“ 添加于2003年,但这个锁本身可以追溯到1997年他的第一个多线程 Python解释器。在Unix系统中,PyThread_type_lock是标准C mutex_t锁的别名。当 Python解释器启动时它初始化:

1
2
3
4
5
6
void
PyEval_InitThreads(void)
{
    interpreter_lock = PyThread_allocate_lock();
    PyThread_acquire_lock(interpreter_lock);
}

解释器中的所有C代码在执行 Python时必须保持这个锁。Guido 最初加这个锁是因为它使用起来简单。而且每次从 CPython 中去除 GIL的尝试会耗费单线程程序太多性能,尽管去除GIL会带来多线程程序性能的提升,但仍是不值得的。

GIL对程序中线程的影响足够简单,你可以在手背上写下这个原则:“一个线程运行 Python,而其他N个睡眠或者等待 I/O.”(即保证同一时刻只有一个线程对共享资源进行存取)Python线程也可以等待threading.Lock或者线程模块中的其他同步对象;线程处于这种状态也称之为”睡眠“。

线程何时切换?一个线程无论何时开始睡眠或等待网络 I/O,其他线程总有机会获取 GIL执行 Python代码。这是协同式多任务处理。CPython也还有抢占式多任务处理。如果一个线程不间断地在Python 2中运行1000字节码指令,或者不间断地在Python 3运行15 毫秒,那么它便会放弃GIL,而其他线程可以运行。把这想象成旧日有多个线程但只有一个CPU时的时间片。我将具体讨论这两种多任务处理。

协同式多任务处理
当一项任务比如网络 I/O启动,而在长的或不确定的时间,没有运行任何Python代码的需要,一个线程便会让出GIL,从而其他线程可以获取GIL而运行Python。这种礼貌行为称为协同式多任务处理,它允许并发,多个线程同时等待不同事件。 也就是说两个线程各自分别连接一个套接字:

1
2
3
4
5
6
def do_connect():
    s = socket.socket()
    s.connect(('python.org', 80))  # drop the GIL
for i in range(2):
    t = threading.Thread(target=do_connect)
    t.start()

两个线程在同一时刻只能有一个执行 Python ,但一旦线程开始连接,它就会放弃 GIL ,这样其他线程就可以运行。这意味着两个线程可以并发等待套接字连接,这是一件好事。在同样的时间内它们可以做更多的工作。 让我们打开盒子,看看一个线程在连接建立时实际是如何放弃GIL 的,在 socketmodule.c 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
/* s.connect((host, port)) method */
static PyObject *
sock_connect(PySocketSockObject *s, PyObject *addro)
{
    sock_addr_t addrbuf;
    int addrlen;
    int res;
    /* convert (host, port) tuple to C address */
    getsockaddrarg(s, addro, SAS2SA(&addrbuf), &addrlen);
    Py_BEGIN_ALLOW_THREADS
    res = connect(s->sock_fd, addr, addrlen);
    Py_END_ALLOW_THREADS
    /* error handling and so on .... */
}

线程正是在Py_BEGIN_ALLOW_THREADS宏处放弃 GIL;它被简单定义为: PyThread_release_lock(interpreter_lock); 当然Py_END_ALLOW_THREADS重新获取锁。一个线程可能会在这个位置堵塞,等待另一个线程释放锁;一旦这种情况发生,等待的线程会抢夺回锁,并恢复执行你的Python代码。简而言之:当N个线程在网络 I/O 堵塞,或等待重新获取GIL,而一个线程运行Python。 下面来看一个使用协同式多任务处理快速抓取许多URL的完整例子。但在此之前,先对比下协同式多任务处理和其他形式的多任务处理。

抢占式多任务处理
Python线程可以主动释放GIL,也可以先发制人抓取GIL 。 让我们回顾下Python是如何运行的。你的程序分两个阶段运行。首先,Python文本被编译成一个名为字节码的简单二进制格式。第二,Python解释器的主回路,一个名叫 pyeval_evalframeex()的函数,流畅地读取字节码,逐个执行其中的指令。 当解释器通过字节码时,它会定期放弃GIL,而不需要经过正在执行代码的线程允许,这样其他线程便能运行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
for (;;) {
    if (--ticker < 0) {
        ticker = check_interval;
        /* Give another thread a chance */
        PyThread_release_lock(interpreter_lock);
        /* Other threads may run now */
        PyThread_acquire_lock(interpreter_lock, 1);
    }
    bytecode = *next_instr++;
    switch (bytecode) {
        /* execute the next instruction ... */
    }
}

默认情况下,检测间隔是1000字节码。所有线程都运行相同的代码,并以相同的方式定期从他们的锁中抽出。在 Python 3GIL 的实施更加复杂,检测间隔不是一个固定数目的字节码,而是15毫秒。然而,对于你的代码,这些差异并不显著。

Python中的线程安全
如果一个线程可以随时失去GIL,你必须使让代码线程安全。 然而Python程序员对线程安全的看法大不同于C或者Java程序员,因为许多Python操作是原子的。 在列表中调用sort(),就是原子操作的例子。线程不能在排序期间被打断,其他线程从来看不到列表排序的部分,也不会在列表排序之前看到过期的数据。原子操作简化了我们的生活,但也有意外。例如,+ = 似乎比 sort() 函数简单,但+ =不是原子操作。你怎么知道哪些操作是原子的,哪些不是? 看看这个代码:

1
2
3
4
n = 0
def foo():
    global n
    n += 1

我们可以看到这个函数用 Python 的标准dis 模块编译的字节码:

1
2
3
4
5
6
>>> import dis
>>> dis.dis(foo)
LOAD_GLOBAL              0 (n)
LOAD_CONST               1 (1)
INPLACE_ADD
STORE_GLOBAL             0 (n)

代码的一行中, n += 1,被编译成 4 个字节码,进行 4 个基本操作:
1 将 n 值加载到堆栈上
2 将常数 1 加载到堆栈上
3 将堆栈顶部的两个值相加
4 将总和存储回 n
记住,一个线程每运行 1000 字节码,就会被解释器打断夺走 GIL 。如果运气不好,这(打断)可能发生在线程加载 n 值到堆栈期间,以及把它存储回 n 期间。很容易可以看到这个过程会如何导致更新丢失:

1
2
3
4
5
6
7
8
9
threads = []
for i in range(100):
    t = threading.Thread(target=foo)
    threads.append(t)
for t in threads:
    t.start()
for t in threads:
    t.join()
print(n)

通常这个代码输出 100,因为 100 个线程每个都递增 n 。但有时你会看到 99 或 98 ,如果一个线程的更新被另一个覆盖。所以,尽管有 GIL,你仍然需要加锁来保护共享的可变状态:

1
2
3
4
5
6
n = 0
lock = threading.Lock()
def foo():
    global n
    with lock:
        n += 1

如果我们使用一个原子操作比如 sort() 函数会如何呢?:

1
2
3
lst = [4, 1, 3, 2]
def foo():
    lst.sort()

这个函数的字节码显示 sort() 函数不能被中断,因为它是原子的:

1
2
3
4
>>> dis.dis(foo)
LOAD_GLOBAL              0 (lst)
LOAD_ATTR                1 (sort)
CALL_FUNCTION            0

一行被编译成 3 个字节码:
1 将 lst 值加载到堆栈上
2 将其排序方法加载到堆栈上
3 调用排序方法
即使这一行 lst.sort() 分几个步骤,调用 sort 自身是单个字节码,因此线程没有机会在调用期间抓取GIL 。我们可以总结为在 sort() 不需要加锁。或者,为了避免担心哪个操作是原子的,遵循一个简单的原则:始终围绕共享可变状态的读取和写入加锁。毕竟,在Python中获取一个 threading.Lock 是廉价的。
尽管GIL不能免除我们加锁的需要,但它确实意味着没有加细粒度的锁的需要(所谓细粒度是指程序员需要自行加、解锁来保证线程安全,典型代表是 Java , 而 CPthon 中是粗粒度的锁,即语言层面本身维护着一个全局的锁机制,用来保证线程安全)。在线程自由的语言比如 Java,程序员努力在尽可能短的时间内加锁存取共享数据,减轻线程争夺,实现最大并行。然而因为在 Python 中线程无法并行运行,细粒度锁没有任何优势。只要没有线程保持这个锁,比如在睡眠,等待I/O, 或者一些其他失去 GIL 操作,你应该使用尽可能粗粒度的,简单的锁。其他线程无论如何无法并行运行。

并发可以完成更快
我敢打赌你真正为的是通过多线程来优化你的程序。通过同时等待许多网络操作,你的任务将更快完成,那么多线程会起到帮助,即使在同一时间只有一个线程可以执行 Python。这就是并发,线程在这种情况下工作良好。
线程中代码运行更快

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import threading
import requests
urls = [...]
def worker():
    while True:
        try:
            url = urls.pop()
        except IndexError:
            break  # Done.
        requests.get(url)
for _ in range(10):
    t = threading.Thread(target=worker)
    t.start()

正如我们所看到的,在 HTTP上面获取一个URL中,这些线程在等待每个套接字操作时放弃GIL,所以他们比一个线程更快完成工作。

Parallelism并行

如果想只通过同时运行 Python 代码,而使任务完成更快怎么办?这种方式称为并行,这种情况GIL是禁止的。你必须使用多个进程,这种情况比线程更复杂,需要更多的内存,但它可以更好利用多个CPU。 这个例子fork出10个进程,比只有1个进程要完成更快,因为进程在多核中并行运行。但是10个线程与1个线程相比,并不会完成更快,因为在一个时间点只有1个线程可以执行 Python:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import os
import sys
nums =[1 for _ in range(1000000)]
chunk_size = len(nums) // 10
readers = []
while nums:
    chunk, nums = nums[:chunk_size], nums[chunk_size:]
    reader, writer = os.pipe()
    if os.fork():
        readers.append(reader)  # Parent.
    else:
        subtotal = 0
        for i in chunk: # Intentionally slow code.
            subtotal += i
        print('subtotal %d' % subtotal)
        os.write(writer, str(subtotal).encode())
        sys.exit(0)
# Parent.
total = 0
for reader in readers:
    subtotal = int(os.read(reader, 1000).decode())
    total += subtotal
print("Total: %d" % total)

因为每个 fork 的进程有一个单独的 GIL,这个程序可以把工作分派出去,并一次运行多个计算。(Jython 和 IronPython 提供单进程的并行,但它们远没有充分实现 CPython 的兼容性。有软件事务内存的 PyPy 有朝一日可以运行更快。如果你对此好奇,试试这些解释器。)

结语
使用线程进行并发 I/O操作,在进程中进行并行计算。 举例讲解Python中的死锁、可重入锁和互斥锁 简单来说,死锁是一个资源被多次调用,而多次调用方都未能释放该资源就会造成死锁,这里结合例子说明下两种常见的死锁情况。 1、迭代死锁 该情况是一个线程“迭代”请求同一个资源,直接就会造成死锁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import threading
import time
class MyThread(threading.Thread):
  def run(self):
    global num
    time.sleep(1)
    if mutex.acquire(1):
      num = num+1
      msg = self.name+' set num to '+str(num)
      print msg
      mutex.acquire()
      mutex.release()
      mutex.release()
num = 0
mutex = threading.Lock()
def test():
  for i in range(5):
    t = MyThread()
    t.start()
if __name__ == '__main__':
  test()

上例中,在run函数的if判断中第一次请求资源,请求后还未release ,再次acquire,最终无法释放,造成死锁。这里例子中通过将print下面的两行注释掉就可以正常执行了 ,除此之外也可以通过可重入锁解决,后面会提到。

2、互相调用死锁
上例中的死锁是在同一个def函数内多次调用造成的,另一种情况是两个函数中都会调用相同的资源,互相等待对方结束的情况。如果两个线程分别占有一部分资源并且同时等待对方的资源,就会造成死锁。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import threading
import time
class MyThread(threading.Thread):
  def do1(self):
    global resA, resB
    if mutexA.acquire():
       msg = self.name+' got resA'
       print msg
       if mutexB.acquire(1):
         msg = self.name+' got resB'
         print msg
         mutexB.release()
       mutexA.release()
  def do2(self):
    global resA, resB
    if mutexB.acquire():
       msg = self.name+' got resB'
       print msg
       if mutexA.acquire(1):
         msg = self.name+' got resA'
         print msg
         mutexA.release()
       mutexB.release()
  def run(self):
    self.do1()
    self.do2()
resA = 0
resB = 0
mutexA = threading.Lock()
mutexB = threading.Lock()
def test():
  for i in range(5):
    t = MyThread()
    t.start()
if __name__ == '__main__':
  test()

二、可重入锁
为了支持在同一线程中多次请求同一资源,python提供了“可重入锁”:threading.RLock。RLock内部维护着一个Lock和一个counter变量,counter记录了acquire的次数,从而使得资源可以被多次require。直到一个线程所有的acquire都被release,其他的线程才能获得资源。这里以例1为例,如果使用RLock代替Lock,则不会发生死锁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import threading
import time
class MyThread(threading.Thread):
  def run(self):
    global num
    time.sleep(1)
    if mutex.acquire(1):
      num = num+1
      msg = self.name+' set num to '+str(num)
      print msg
      mutex.acquire()
      mutex.release()
      mutex.release()
num = 0
mutex = threading.RLock()
def test():
  for i in range(5):
    t = MyThread()
    t.start()
if __name__ == '__main__':
  test()

和上面那个例子的不同之处在于threading.Lock()换成了threading.RLock() 。

三、互斥锁

python threading模块有两类锁:互斥锁(threading.Lock )和可重用锁(threading.RLock)。两者的用法基本相同,具体如下:

1
2
3
4
lock = threading.Lock()
lock.acquire()
dosomething……
lock.release()

RLock的用法是将threading.Lock()修改为threading.RLock()。便于理解,先来段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import threading
import time
class mythread(threading.Thread):
  def __init__(self,threadname):
    threading.Thread.__init__(self,name = threadname)
  def run(self):
    global x
    for i in range(3):
      x = x + 1
    time.sleep(5)
    print x
tl = []
for i in range(10):
  t = mythread(str(i))        # 类实例化
  tl.append(t)           # 将类对象添加到列表中
x=0                 # 将x赋值为0
for i in tl:
  i.start()

这里执行的结果和想想的不同,结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
30
30
30
30
30
30
30
30
30
30

为什么结果都是30呢?关键在于global行和time.sleep行。
1、由于x是一个全局变量,所以每次循环后 x 的值都是执行后的结果值;
2、由于该代码是多线程的操作,所以在sleep 等待的时候,之前已经执行完成的线程会在这等待,而后续的进程在等待的5秒这段时间也执行完成 ,等待print。同样由于global 的原理,x被重新斌值。所以打印出的结果全是30 ;
3、便于理解,可以尝试将sleep等注释,你再看下结果,就会发现有不同。

在实际应用中,如抓取程序等,也会出现类似于sleep等待的情况。在前后调用有顺序或打印有输出的时候,就会现并发竞争,造成结果或输出紊乱。这里就引入了锁的概念,上面的代码修改下,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import threading
import time
class mythread(threading.Thread):
  def __init__(self,threadname):
    threading.Thread.__init__(self,name = threadname)
  def run(self):
    global x
    lock.acquire()
    for i in range(3):
      x = x + 1
    time.sleep(5)
    print x
    lock.release()
lock = threading.Lock()
tl = []
for i in range(10):
  t = mythread(str(i))
  tl.append(t)
x=0
for i in tl:
  i.start()

执行的结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
3
6
9
12
15
18
21
24
27
30

加锁的结果会造成阻塞,而且会造成开锁大。会根据顺序由并发的多线程按顺序输出,如果后面的线程执行过快,需要等待前面的进程结束后其才能结束 --- 写的貌似有点像队列的概念了 ,不过在加锁的很多场景下确实可以通过队列去解决。 ​


转载请注明本网址。