我6岁的时候,有一个八音盒。我给它上发条,八音盒顶部的芭蕾舞演员就会旋转,而内部机构会发出“一闪一闪,满天都是小星星”的叮当声。那东西一定很俗气,但我喜欢那个八音盒,想知道它是如何工作的。后来我拆开它,看到里面有一个简单的机构,一个拇指大小的金属圆筒镶嵌在机身内部,当它转动时,它会拨动钢梳齿发出这些音符。在程序员的所有特质中,好奇心是必不可少的。当我打开八音盒往里看时,我可以看出,即使我没有成长为一个出色的程序员,至少我已经成长为一个好奇的人。奇怪的是,多年来我一直在编写Python程序并且对全局解释器锁(GIL)一直有错误的想法,因为我对它的工作原理从来没有足够的好奇。我遇到了一些对此同样犹豫和无知的人。是时候让我们打开盒子仔细看看了。下面我们来解密CPython解释器的源码,了解一下GIL到底是什么,为什么会存在于Python中,以及它对多线程程序有何影响。我将通过示例帮助您深入了解GIL。您将学习如何编写快速且线程安全的Python代码,以及如何在线程和进程之间进行选择。(我在本文中只描述CPython,而不是Jython、PyPy或IronPython。因为绝大多数程序员仍然使用CPython来实现Python。)瞧,全局解释器锁(GIL)在这里:staticPyThread_type_lockinterpreter_lock=0;/*ThisistheGIL*/这行代码取自ceval.c——CPython2.7解释器的源代码,GuidovanRossum的评论“ThisistheGIL”是在2003年添加的,但锁本身可以追溯到他的第一个more比1997ThreadedPython解释器。在Unix系统上,PyThread_type_lock是标准Cmutex_t锁的别名。它在Python解释器启动时被初始化:voidPyEval_InitThreads(void){interpreter_lock=PyThread_allocate_lock();PyThread_acquire_lock(interpreter_lock);}解释器中的所有C代码在执行Python时必须持有这个锁。Guido最初添加此锁是因为它使用简单。并且每一次从CPython中移除GIL的尝试都会在单线程程序中付出太多的性能代价,虽然移除GIL会在多线程程序中带来性能提升,但仍然不值得。(前者是Guido最关心的,也是没有去掉GIL的最重要原因。1999年做了简单的尝试,最后的结果是单线程程序速度下降了差不多2次。)GIL对程序中线程的影响足够简单,你可以把这个原理写在手背上:“一个线程运行Python,而另一个线程休眠或等待I/O。”(即保证同一时刻只有一个线程访问共享资源)Python线程也可以在threading模块中等待threading.Lock或其他同步对象;处于这种状态的线程也称为“休眠”。什么时候线程切换?每当一个线程开始休眠或等待网络I/O时,其他线程总是有机会获取GIL来执行Python代码。这就是协作式多任务处理。CPython也有抢占式多任务处理。如果一个线程在Python2中不间断地运行1000条字节码指令,或者在Python3中不间断地运行15毫秒,它就会放弃GIL,其他线程可以运行。把它想象成过去有多个线程但只有一个CPU的时间片。我将详细讨论这两种类型的多任务处理。将Python想象成旧的大型机,多个任务共享一个CPU。协作式多任务处理当启动一项任务(例如网络I/O)而无需长时间或不确定的时间运行任何Python代码时,一个线程放弃GIL,以便其他线程可以获得GIL并运行Python。这种礼貌的行为称为合作多任务处理,它允许并发;多个线程同时等待不同的事件。也就是说,两个线程各连接一个socket:defdo_connect():s=socket.socket()s.connect(('python.org',80))#droptheGILforiinrange(2):t=threading.Thread(target=do_connect)t.start()两个线程中只有一个可以同时执行Python,但是一旦线程开始连接,它就会放弃GIL,让其他线程运行。这意味着两个线程可以同时等待套接字连接,这是一件好事。他们可以在同样的时间内完成更多的工作。让我们打开盒子,看看在建立连接时线程实际上如何放弃GIL,在socketmodule.c中:/*s.connect((host,port))method*/staticPyObject*sock_connect(PySocketSockObject*s,PyObject*addro){sock_addr_taddrbuf;intaddrlen;intres;/*convert(host,port)tupletoCaddress*/getsockaddrarg(s,addro,SAS2SA(&addrbuf),&addrlen);Py_BEGIN_ALLOW_THREADSres=connect(s->sock_fd,addr,addrlen);Py_END_ALLOW_THREADS/*errorhandlingandsoon....*/}线程在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,以便其他线程可以运行:for(;;){if(--ticker<0){ticker=check_interval;/*Giveanotherthreadachance*/PyThread_release_lock(interpreter_lock);/*Otherthreadsmayrunnow*/PyThread_acquire_lock(interpreter_lock,1);}bytecode=*next_instr++;switch(bytecode){/*executethenextinstruction...*/}}默认检测间隔为1000字节码.所有线程都运行相同的代码,并以相同的方式周期性地从它们的锁中取出。Python3中GIL的实现比较复杂,检测间隔不是固定的字节码数,而是15毫秒。但是,对于您的代码,这些差异并不重要。Python中的线程安全将多个线程编织在一起并且需要技巧。如果一个线程随时可能丢失GIL,则必须使代码线程安全。然而,Python程序员对线程安全的看法与C或Java程序员截然不同,因为许多Python操作是原子的。对列表调用sort()是原子操作的一个例子。一个线程在排序期间不能被中断,其他线程永远看不到列表的排序部分,也看不到列表排序之前的陈旧数据。原子操作简化了我们的生活,但也有惊喜。例如,+=看起来比sort()函数更简单,但+=不是原子操作。你怎么知道哪些操作是原子的,哪些不是?查看这段代码:n=0deffoo():globalnn+=1我们可以看到使用Python的标准dis模块编译的这个函数的字节码:>>>importdis>>>dis.dis(foo)LOAD_GLOBAL0(n)LOAD_CONST1(1)INPLACE_ADDSTORE_GLOBAL0(n)一行代码中,n+=1,被编译成4个字节码,进行4个基本操作:将n的值加载到上面的栈中将常量1加载到栈中将两个值相加在堆栈顶部将总和存储回n请记住,线程运行的每1000个字节都会被解释器中断,并且GIL会被带走。如果你不走运,这个(中断)可能发生在线程将n的值加载到堆栈和将它存储回n之间。很容易看出这个过程如何导致更新丢失:threads=[]foriinrange(100):t=threading.Thread(target=foo)threads.append(t)fortinthreads:t.start()fortinthreads:t.join()print(n)通常此代码打印100,因为100个线程每个递增n。但有时如果一个线程的更新被另一个线程覆盖,您会看到99或98。所以,尽管有GIL,你仍然需要锁来保护共享的可变状态:n=0lock=threading.Lock()defffoo():globalnwithlock:n+=1如果我们使用原子操作比如sort()函数呢?:lst=[4,1,3,2]defffoo():lst.sort()这个函数的字节码表明sort()函数不能被中断,因为它是原子的:>>>dis.dis(foo)LOAD_GLOBAL0(lst)LOAD_ATTR1(sort)CALL_FUNCTION0一行被编译成3个字节码:将第一个值加载到堆栈上将其排序方法加载到堆栈上调用排序方法即使这一行是lst.sort()步骤,调用排序本身就是单字节码,所以线程在调用的过程中是没有机会抢到GIL的。我们可以得出结论,不需要锁定sort()。或者,为了避免担心哪些操作是原子操作,遵循一个简单的原则:始终锁定共享可变状态的读写。毕竟,在Python中获取threading.Lock很便宜。GIL虽然不能免除我们对锁的需求,但确实意味着不需要加细粒度的锁(所谓细粒度就是程序员需要自己加锁和加锁来保证线程安全,典型的代表是Java,CPthon是Coarse-grainedlocks,即语言层面本身维护全局锁机制,保证线程安全)。在Java这样的无线程语言中,程序员试图在尽可能短的时间内加锁访问共享数据,减少线程争用,实现最大并行度。但是,由于线程在Python中不能并行运行,因此细粒度锁定没有优势。只要没有线程持有锁,例如休眠、等待I/O或其他一些GIL丢失操作,您就应该尽可能使用粗粒度、简单的锁。无论如何,其他线程不能并行运行。并发可以更快地完成我敢打赌你真正想要的是通过多线程优化你的程序。通过同时等待许多网络操作,您的任务将更快完成,因此多线程会有所帮助,即使一次只有一个线程可以执行Python。这就是并发性,线程在这种情况下工作得很好。代码在线程中运行得更快target=worker)t.start()正如我们在通过HTTP获取URL时看到的那样,这些线程在等待每个套接字操作时放弃了GIL,因此它们完成工作的速度比单个线程快。并行性如果您想通过同时运行Python代码来加快任务速度怎么办?这种方法称为并行,在这种情况下禁止使用GIL。你必须使用多个进程,这比线程更复杂,需要更多的内存,但它可以更好地利用多个CPU。此示例分叉10个进程,比仅1个进程完成得更快,因为这些进程跨多个内核并行运行。但是10个线程不会比1个线程更快完成,因为在一个时间点只有1个线程可以执行Python:importosimportsysnums=[1for_inrange(1000000)]chunk_size=len(nums)//10readers=[]whilenums:chunk,nums=nums[:chunk_size],nums[chunk_size:]reader,writer=os.pipe()ifos.fork():readers.append(reader)#Parent.else:subtotal=0foriichunk:#Intentionallyslowcode.subtotal+=iprint('subtotal%d'%subtotal)os.write(writer,str(subtotal).encode())sys.exit(0)#Parent.total=0forreaderinreaders:subtotal=int(os.read(reader,1000).decode())total+=subtotalprint("Total:%d"%total)因为每个分叉进程都有一个单独的GIL,所以这个程序可以委派工作并一次运行多个计算。(Jython和IronPython提供单进程并行性,但它们远未完全与CPython兼容。具有软件事务内存的PyPy总有一天会运行得更快。如果您好奇,请尝试这些解释器。)结论现在您已经打开音乐box,看看它的简单机制,你就会知道编写快速运行、线程安全的Python代码所需知道的一切。使用线程进行并发I/O操作并在进程内执行并行计算。原理很简单,不用手写。
