资讯专栏INFORMATION COLUMN

如何编写快速且线程安全的Python代码

B0B0 / 1189人阅读

摘要:其次,解释器的主循环,一个名为的函数,读取字节码并逐个执行其中的指令。所有线程都运行相同的代码,并以相同的方式定期从它们获取锁定。无论如何,其他线程无法并行运行。

概述

如今我也是使用Python写代码好多年了,但是我却很少关心GIL的内部机制,导致在写Python多线程程序的时候。今天我们就来看看CPython的源代码,探索一下GIL的源码,了解为什么Python里要存在这个GIL,过程中我会给出一些示例来帮助大家更好的理解GIL。

GIL概览

有如下代码:

static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */

这行代码位于Python2.7源码ceval.c文件里。在类Unix操作系统中,PyThread_type_lock对应C语言里的mutex_t类型。在Python解释器开始运行时初始化这个变量

void
PyEval_InitThreads(void)
{
    interpreter_lock = PyThread_allocate_lock();
    PyThread_acquire_lock(interpreter_lock);
}

所有Python解释器里执行的c代码都必须获取这个锁,作者一开始为求简单,所以使用这种单线程的方式,后来每次想移除时,都发现代价太高了。

GIL对程序中的线程的影响很简单,你可以在手背上写下这个原则:“一个线程运行Python,而另外一个线程正在等待I / O.”Python代码可以使用threading.Lock或者其他同步对象,来释放CPU占用,让其他程序得以执行。

什么时候线程切换? 每当线程开始休眠或等待网络I / O时,另一个线程都有机会获取GIL并执行Python代码。CPython还具有抢先式多任务处理:如果一个线程在Python 2中不间断地运行1000个字节码指令,或者在Python 3中运行15毫秒,那么它就会放弃GIL而另一个线程可能会运行。

协作式多任务

每当运行一个任务,比如网络I/O,持续的时间很长或者无法确定运行时间,这时可以放弃GIL,这样另一个线程就可以接受并运行Python。 这种行为称为协同多任务,它允许并发; 许多线程可以同时等待不同的事件。
假设有两个链接socket的线程

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,以便其他线程可以运行。这意味着两个线程都可以等待它们的套接字同时连接,他们可以在相同的时间内完成更多的工作。
接下来,让我们打开Python的源码,来看看内部是如何实现的(位于socketmodule.c文件里):

 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用于获取GIL锁,这时,当前现在有可能会卡住,等待其他现在释放GIL锁。

优先权式多任务

Python线程可以自愿释放GIL,但它也可以抢先获取GIL。
让我们回顾一下如何执行Python。 您的程序分两个阶段运行。 首先,您的Python文本被编译为更简单的二进制格式,称为字节码。 其次,Python解释器的主循环,一个名为PyEval_EvalFrameEx()的函数,读取字节码并逐个执行其中的指令。当解释器逐步执行您的字节码时,它会定期删除GIL,而不会询问正在执行其代码的线程的权限,因此其他线程可以运行:

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 3中,GIL的实现更复杂,检查间隔不是固定数量的字节码,而是15毫秒。 但是,对于您的代码,这些差异并不重要。

Python线程安全

如果某个线程在任何时候都可能丢失GIL,那么您必须使代码具有线程安全性。 然而,Python程序员对线程安全的看法与C或Java程序员的不同,因为许多Python操作都是原子的。

原子操作的一个示例是在列表上调用sort()。 线程不能在排序过程中被中断,其他线程永远不会看到部分排序的列表,也不会在列表排序之前看到过时的数据。 原子操作简化了我们的生活,但也有惊喜。 例如,+ =似乎比sort()简单,但+ =不是原子的。 那我们怎么知道哪些操作是原子的,哪些不是?

例如有代码如下:

n = 0

def foo():
    global n
    n += 1

我们可以使用python的dis模块获取这段代码对应的字节码:

>>> import dis
>>> dis.dis(foo)
LOAD_GLOBAL              0 (n)
LOAD_CONST               1 (1)
INPLACE_ADD
STORE_GLOBAL             0 (n)

可以看出,n += 1这行代码,编译出了4个字节码:

将n的值加载到堆栈上

将常量1加载到堆栈上

将堆栈顶部的两个值相加

将总和存回n

请记住,一个线程的每1000个字节码被解释器中断以释放GIL。 如果线程不幸运,这可能发生在它将n的值加载到堆栈上以及何时将其存储回来之间。这样就容易导致数据丢失:

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个线程中的每一个都增加了1。 但有时你会看到99或98,这就是其中一个线程的更新被另一个线程覆盖。所以,尽管有GIL,你仍然需要锁来保护共享的可变状态:

n = 0
lock = threading.Lock()

def foo():
    global n
    with lock:
        n += 1

同样的,如果我们使用sort()函数:

lst = [4, 1, 3, 2]

def foo():
    lst.sort()

翻译成字节码如下:

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

可以看出,sort()函数被翻译成了一条指令,执行过程不会被打断。

将lst的值加载到堆栈上

将其排序方法加载到堆栈上

调用排序方法

即使lst.sort()需要几个步骤,sort调用本身也是一个字节码,因此不会被打断。 我们可以得出结论,我们不需要锁定sort()。 或者,请遵循一个简单的规则:始终锁定共享可变状态的读写。 毕竟,获取Python中的threading.Lock花销很低。
虽然GIL不能免除锁的需要,但它确实意味着不需要细粒度的锁定。 在像Java这样的自由线程语言中,程序员努力在尽可能短的时间内锁定共享数据,以减少线程争用并允许最大并行度。 但是,由于线程无法并行运行Python,因此细粒度锁定没有任何优势。 只要没有线程在休眠时持有锁,I / O或其他一些GIL丢弃操作,你应该使用最粗糙,最简单的锁。 无论如何,其他线程无法并行运行。

并发提供更好的性能

在诸如网络请求等I/O型的场景中,使用Python多线程可以带来很高的性能提升,因为在I/O场景中,大多数线程都在等待I/O以进行接下来的操作,所以即使单CPU,也能大大提高性能。比如下面这样的代码:

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,因此它们比单个线程性能更高。

并行

如果你的任务一定要多线程才能更好的完成,那么,对于Python来说,多线程是不合适的,这种情况下,你得使用多进程,因为每个进程都是多带带的运行环境,并且可以使用多核,但这会带来更高的性能开销。下面的代码就是使用多进程来运行任务,每个进程里只有一个线程。

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)

因为每个进程都拥有多带带的GIL,所以这段代码可以在多核CPU上并行执行。

总结

由于Python GIL的存在,导致Python中一个进程下的多个线程无法并行执行,在I/O密集型的场景中,多线程依然能带来比较好的性能,但是在CPU密集型的场景中,多线程无法带来性能的提升。但同时也是由于GIL的存在,我们在单进程中,线程安全也比较容易达到。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/43882.html

相关文章

  • 【须弥SUMERU】宜信分布式安全服务编排实践

    摘要:通过可视化操作,将安全任务灵活编排成扫描流程。失效转移失效转移又称故障切换,指系统中其中一项设备或服务失效而无法运作时,另一项设备或服务即可自动接手原失效系统所执行的工作,在须弥用于保障任务执行过程中的执行状态。 概要 1.分布式安全服务编排概念 2.须弥(Sumeru)关键实现思路 3.应用场景 前言 在笔者理解,安全防御的本质之一是增加攻击者的攻击成本,尤其是时间成本,那么从防御...

    syoya 评论0 收藏0
  • 100 个基本 Python 面试问题第二部分(41-60)

    摘要:回到目录评论区抽粉丝送书啦欢迎大家在评论区提出意见和建议抽两位幸运儿送书,实物图如下开发从入门到精通内容简介案例教学。 ? 作者主页:海拥 ? 作者简介:?CSDN...

    Tikitoo 评论0 收藏0
  • 【人情事故】做了3年销售一事无成,转行软件测试成功后我就拿了8k!

    摘要:以下为我的真实案例以我真实案例分享,希望给更多决定重新开始的人以鼓励我已经上班很久了,目前在中软做软件测试工程师,月薪,现在回想起来,仍然庆幸我当初的决定。  今天跟大家分享我的故事,或许你也曾像他那样迷茫过。17年软件工程专业专科毕业之后做了3年的销售工作,最后决定还是再次提升专业技能,...

    _Zhao 评论0 收藏0
  • JavaScript 工作原理之六-WebAssembly 对比 JavaScript 及其使用场景

    摘要:现在,我们将会剖析的工作原理,而最重要的是它和在性能方面的比对加载时间,执行速度,垃圾回收,内存使用,平台访问,调试,多线程以及可移植性。目前,是专门围绕和的使用场景设计的。目前不支持多线程。 原文请查阅这里,略有改动,本文采用知识共享署名 4.0 国际许可协议共享,BY Troland。 本系列持续更新中,Github 地址请查阅这里。 这是 JavaScript 工作原理的第六章...

    jay_tian 评论0 收藏0

发表评论

0条评论

B0B0

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<