第24讲 不可忽视的多线程及并发问题

第24讲 不可忽视的多线程及并发问题

既然我们说到了服务器端的开发,我们就不得不提起多线程和并发的问题,因为如果没有多线程和并发,是不可能做网络服务器端的,除非你的项目是base在Nginx或者Apache之上的。

多线程和并发究竟有什么区别和联系?

提到并发,不得不提到并行,所以我就讲这三个概念:并发、并行,以及多线程。作为初学者,你或许不太明白,多线程和并发究竟有什么区别和联系?下面我们就分别来看看。

并发出现在电脑只有一个CPU的情况下,那如果有多个线程需要操作,该怎么办呢?CPU不可能一次只运行一个程序,运行完一个再运行第二个,这个效率任谁都忍受不了啊!所以,就想了个办法。

CPU将运行的线程分成若干个CPU时间片来运行。不运行的那个线程就挂起,运行的时候,那个线程就活过来,切换地特别快,就好像是在同时运行一样。

你可以想象这个场景,有一个象棋大师,一个人对十个对手下棋,那十个人轮流和他下。大师从1号棋手这里开始下,下完1号走到2号的棋手面前,下2号棋手的棋,一直轮流走下去,直到再走回1号棋手这里再下一步。只要象棋大师下象棋下得足够快,然后他移动到下一位棋手这里又移动得足够快,大家都会觉得好像有十位象棋大师在和十个对手下棋。事实上只有一位象棋大师在下棋,只是他移动得很快而已。

并行和并发不同,并行是出现在多个物理CPU的情况下。在这种情况下,并行是真正的并发状态,是在物理状态下的并发运行。所以,并行是真的有几位象棋大师在应对几个对手。当然在并行的同时,CPU也会进行并发运算。

多线程是单个进程的切片,单个进程中的线程中的内存和资源都是共享的,所以线程之间进行沟通是很方便的。

多线程的意义,就好比一个厨师,他掌管了三个锅,一个锅在煮排骨,一个锅在烧鱼,另一个锅在煮面,这三个锅内容不同,火候不同,但是所有的调料和资源,包括菜、油、水、盐、味精、糖、酱油等等,都来自同一个地方(也就是资源共享),而厨师自己是一个进程,他分配了三个线程(也就是三个锅),这三个锅烧着不同的东西,三个食物或许不是同时出锅的,但是厨师心里有数,什么时候这个菜可以出锅,什么时候这个菜还需要煮。这就是多线程的一个比喻。

我们在编写网络服务器的时候,多线程和并发的问题是一定会考虑的。我们说的网络并发和CPU的并发可以说是异曲同工,也就是说,网络并发的意义是,这个网络服务器可以同时支撑多少个用户同时登陆,或者同时在线操作

为什么Python用多个CPU的时候会出现问题?

那么我们又回头来看,为什么Python、Ruby或者Node.js,在利用多个CPU的时候会出现问题呢?这是因为,它们是使用C/C++语言编写的。是的,问题就在这里。

我们后续的内容还是会用Python来写,所以我们先来看看Python的多线程问题。Python有个GIL(Global Interpreter Lock,全局解释锁),问题就出在GIL上。

使用C语言编写的Python版本(后面简写为C-Python)的线程是操作系统的原生线程。在Linux上为pthread,在Windows上为Win thread,完全由操作系统调度线程的执行。

一个Python解释器进程内有一条主线程,以及多条用户程序的执行线程。即使在多核CPU平台上。由于GIL的存在,所以会禁止多线程的并行执行。这是为什么呢?

因为Python解释器进程内的多线程是合作多任务方式执行的。当一个线程遇到I/O(输入输出)任务时,将释放GIL锁。计算密集型(以计算为主的逻辑代码)的线程在执行大约100次解释器的计步时,将释放GIL锁。你可以将计步看作是Python虚拟机的指令。计步实际上与CPU的时间片长度无关。我们可以通过Python的库sys.setcheckinterval()设置计步长度来控制GIL的释放事件。

在单核的CPU上,数百次间隔检查才会导致一次线程切换。在多核CPU上,就做不到这些了。从Python 3.2开始就使用新的GIL锁了。在新的GIL实现中,用一个固定的超时时间来指示当前的线程放弃全局锁。在当前线程保持这个锁,且其他线程请求这个锁的时候,当前线程就会在五毫秒后被强制释放掉这个锁。

我们如果要实现并行,利用Python的多线程效果不好,所以我们可以创建独立的进程来实现并行化。Python 2.6(含)以上版本引进了multiprocessing这个多进程包。

我们也可以把多线程的关键部分用C/C++写成Python扩展,通过ctypes使Python程序直接调用C语言编译的动态库的导出函数来使用。

C-Python的GIL的问题存在于C-Python的编写语言,原生语言C语言中,由于GIL为了保证Python解释器的顺利运行,所以事实上,多线程只是模拟了切换线程而已。这么做的话,如果你使用的是IO密集型任务的时候,就会提高速度。为什么这么说?

因为写文件读文件的时间完全可以将GIL锁给释放出来,而如果是计算密集型的任务,或许将会得到比单线程更慢的速度。为什么呢?事实上GIL是一个全局的排他锁,它并不能很好地利用CPU的多核,相反地,它会将多线程模拟成单线程进行上下文切换的形式进行运行。

我们来看一下,在计算密集型的代码中,单线程和多线程的比较。

单线程版本:

from threading import Thread
  import time
  def my_counter():
      i = 0
      for x in range(10000):
          i = i + 1
      return True
  def run():
      thread_array = {}
      start_time = time.time()
      for tt in range(2):
          t = Thread(target=my_counter)
          t.start()
          t.join()
      end_time = time.time()
      print("count time: {}".format(end_time - start_time))
  if __name__ == '__main__':
      run()

多线程版本:

from threading import Thread
  import time
  def my_counter():
      i = 0
      for x in range(10000):
          i = i + 1
      return True
  def run():
      thread_array = {}
      start_time = time.time()
      for tt in range(2):
          t = Thread(target=my_counter)
          t.start()
          thread_array[tid] = t
      for i in range(2):
          thread_array[i].join()
      end_time = time.time()
      print("count time: {}".format(end_time - start_time))
  if __name__ == '__main__':
      run()

当然,我们还可以把这个ranger的数字改得更大,看到更大的差异。

当计步完成后,将会达到一个释放锁的阀值,释放完后立刻又取得锁,然而这在单CPU环境下毫无问题,但是多CPU的时候,第二块CPU正要被唤醒线程的时候,第一块CPU的主线程又直接取得了主线程锁,这时候,就出现了第二块CPU不停地被唤醒,第一块CPU拿到了主线程锁继续执行内容,第二块继续等待锁,唤醒、等待,唤醒、等待。这样,事实上只有一块CPU在执行指令,浪费了其他CPU的时间。这就是问题所在。

这也就是C语言开发的Python语言的问题。当然如果是使用Java写成的Python(Jython)和.NET下的Python(Iron Python),并没有GIL的问题。事实上,它们其实连GIL锁都不存在。我们也可以使用新的Python实作项目PyPy。所以,这些问题事实上是由于实现语言的差异造成的。

如何尽可能利用多线程和并发的优势?

我们来尝试另一种解决思路,我们仍然用的是C-Python,但是我们要尽可能使之能利用多线程和并发的优势,这该怎么做呢?

multiprocess是在Python 2.6(含)以上版本的提供是为了弥补GIL的效率问题而出现的,不同的是它使用了多进程而不是多线程。每个进程有自己的独立的GIL锁,因此也不会出现进程之间,CPU进行GIL锁的争抢问题,因为都是独立的进程。

当然multiprocessing也有不少问题。首先它会增加程序实现时线程间数据通信和同步的困难。

就拿计数器来举例子。如果我们要多个线程累加同一个变量,对于thread来说,申明一个global变量,用thread.Lock的context就可以了。而multiprocessing由于进程之间无法看到对方的数据,只能通过在主线程申明一个Queue,put再get或者用共享内存、共享文件、管道等等方法。

我们可以来看一下multiprocess的共享内容数据的方案。

from multiprocessing import Process, Queue
  def f(q):
      q.put([4031, 1024, 'my data'])
  if __name__ == '__main__':
      q = Queue()
      p = Process(target=f, args=(q,))
      p.start()
      print q.get()
      p.join()

这样的方案虽说可行,但是编码效率变得比较低下,但是也是一种权宜之计吧。

小结

我们来总结一下今天的内容。

给你留一个小问题,如果Python以多进程方式进行操作,那么如果我们网络服务器是用Python编写的,其中一个Python进程崩溃或者报错了,有什么办法可以让其复活?

欢迎留言说出你的看法。我在下一节的挑战中等你!