You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

9.9 KiB

第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有个GILGlobal 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写成的PythonJython和.NET下的PythonIron Python并没有GIL的问题。事实上它们其实连GIL锁都不存在。我们也可以使用新的Python实作项目PyPy。所以这些问题事实上是由于实现语言的差异造成的。

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

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

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

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

就拿计数器来举例子。如果我们要多个线程累加同一个变量对于thread来说申明一个global变量用thread.Lock的context就可以了。而multiprocessing由于进程之间无法看到对方的数据只能通过在主线程申明一个Queueput再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()

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

小结

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

  • 我首先介绍了几个概念。并发是单个CPU之间切换多线程任务的操作。并行是多个CPU同时分配和运行多线程任务的操作。线程是进程内的独立任务单元但是共享这个进程的所有资源。网络的并发指的是服务器同时可以承载多少数量的人数和任务。

  • 而C语言编写的Python有GIL锁的问题会让其多线程计算密集型的任务效率更低解决方案有利用多进程解决问题 或者 更换Python语言的实现版本比如PyPy或者JPython等等。

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

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