0%

多线程与多进程, 基于Python实现

作为一个程序员,我发现我不会写多进程和多线程的程序,实在羞愧,在赶完毕业设计后准备开一个多线程编程的坑。本文是我这种小白从无到有的学习收获,内容较多,还请耐心观看。因疫情原因没在学校,手头没有一本靠谱的《操作系统》的书籍,大多东西为网上搜刮而来后整理,可能不严谨。本文收录:

  • 引入线程、进程的基本概念;
  • 多进程编程:变量引用、性能分析、进程池和进程通信;
  • 多线程编程:线程锁、线程的局部变量;
  • 实际项目:暂时的目标是多线程爬虫,内容过多准备放下一个文章(同时也包括防止线程死锁、协程等)。

从物理机开始

本文以Linux操作为主,Windows操作不太了解。首先先从物理机出发,了解物理CPU,CPU的核,线程是什么概念。物理CPU指实际存在的CPU处理器,安装在PC主板或服务器上。首先查看物理CPU个数,查看电脑有几个CPU:

grep 'physical id' /proc/CPUinfo | sort -u

执行结果反馈如下:(后期解释为什么是12个id为0的记录)

其id均为0,表示我电脑有一个CPU(从0开始编号)。CPU中包含物理内核,比如多核CPU,单核CPU。这个多核或者单核已经集成在CPU内部了,那么来查看一下核心数量,就是买电脑的时候常听商家常说的4核CPU或者8核CPU:

grep 'core id' /proc/CPUinfo | sort -u | wc -l

我这里输出的结果为6,表示我的是6核CPU。同时,你在买电脑的时候听说过,6核12线程,4核8线程的CPU,那么,再看一下自己的电脑支持几个线程:(说了半天终于到线程了)。

grep 'processor' /proc/CPUinfo | sort -u | wc -l

输出的结果是12,表示我的电脑的处理器是一个CPU,且是6核心12线程的。所谓的6核12线程,6核指的是物理核心。用Intel的超线程技术(HT, Hyper-threading)将物理核虚拟而成的逻辑处理单元,现在大部分的主机的CPU都在使用HT技术,用一个物理核模拟两个虚拟核,即每个核两个线程,总数为12线程。所以在操作系统看来是12个核,但实际是一个物理CPU中的6个物理内核虚拟出来的12个线程:

此时我们需要知道:一个核心只能同时执行一个线程

进程与线程的概念

进程

当写完一份程序,编译为二进制的可执行文件并执行时:可执行的.exe文件可以理解为一种程序,程序本身也是指令的集合,而进程才是程序(那些指令)的真正运行。进程本身不会运行,运行的程序叫进程。进程会标记程序要访问的地址、要执行的操作等,执行完毕后进程会被销毁。(如有兴趣请参考《操作系统》的进程控制块,我快忘光了)

若干进程有可能与同一个程序有关系,如循序和平行。进程A需要得到进程B的处理结果才能继续运行,所以进程A要等待进程B执行完,这叫循序;进程C和进程D没有关系,这俩可以同时进行,这叫平行

举个生动的例子:某生产流水线,得先通过质量安检才能包装,所以质量检查和包装这两个进程不能同时进行,有明显的先后顺序,这叫循序;对于没有明显先后顺序的,比如电脑打开两个软件,一个负责聊天,一个负责放音乐,这两个进程毫不相干,所以同一时刻谁先执行谁后执行无所谓的,没有明显的先后顺序,这叫平行

也许你会问:音乐软件和聊天软件这两个进程不是同一时刻在一起执行吗?其实不是的,计算机只能串行执行不同的进程,即执行进程A的时候不能执行进程B,所以同一时刻,计算机只能执行一个进程。你所谓的边听音乐边聊天,其实是计算机中断实现的。先执行聊天进程,在以极低的间隔切换到音乐进程,只是这个时间间隔短暂,短暂到肉眼无法察觉,你感受不到,所以你的体会是同时进行的,但实际是单独进行的。

而每一个进程都有一个主线程,一个CPU核心在同一时刻只能执行一个线程。所以,当系统内存在的多个进程的时候,操作系统会根据进程的优先级算法来决定先执行谁,后执行谁,A的优先级高,那就先执行A。确定进程的优先级有很多算法,详情参考《操作系统》。以我的电脑为例,启动系统,打开一堆软件,肯定不止12个进程,而多个进程以优先级的形式在仅12个核的CPU上轮番执行,保证系统的有条不紊。

进程也是线程的容器。最简单的比喻多线程就像火车的每一节车厢,而进程则是火车。车厢离开火车是无法跑动的,同理火车也不可能只有一节车厢,多线程的出现就是为了提高效率。

总结一下:进程是操作系统进行资源(包括CPU、内存、磁盘IO等)分配的最小单位,而进程中的线程是CPU调度和分配的基本单位。我们打开的聊天工具,浏览器都是一个进程。进程可能有多个子任务,比如聊天工具要接受消息,发送消息,这些子任务就是线程。

而资源分配给进程,且不同进程之间不共享资源,线程共享进程资源。

线程

线程是操作系统能够进行运算调度的最小单位。它被包涵在进程之中,一条线程指的是进程中一个单一顺序的控制流,一个进程中可以有多个线程,每条线程并行执行不同的任务,线程的运行中需要使用计算机的内存资源和CPU。

同一进程中的多条线程将共享该进程中的全部系统资源,如访问数据、地址空间等等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage)。

在多核或多CPU,或支持HT的CPU上使用多线程程序设计的好处是显而易见,即提高了程序的执行吞吐率。多核CPU在毕竟同一时刻跑多个任务,单核CPU同一时刻只能跑一个任务。

总结:进程是资源分配的最小单位,线程是CPU调度的最小单位。线程和进程的区别在于,子进程和父进程有不同的代码和数据空间,而多个线程则共享数据空间,每个线程有自己的执行堆栈和程序计数器为其执行上下文。

串行、并行、并发

串行:多个任务,执行时一个执行完再执行另一个。比喻:吃完饭再看视频。

并发:多个线程在单个核心运行,同一时间一个线程运行,系统不停切换线程,看起来像同时运行,实际上是线程不停切换。比喻: 一会跑去厨房吃饭,一会跑去客厅看视频。

并行:每个线程分配给独立的核心,线程同时运行。比喻:一边吃饭一边看视频。

在了解完串行、并行、并发的概念后,可以更多的理解多进程和多线程了:

多进程

当你运行一个程序,你就启动了一个进程。在同一个时间里,同一个计算机系统中如果允许两个或两个以上的进程处于运行状态,这便是多任务。

现代的操作系统几乎都是多任务操作系统,能够同时管理多个进程的运行。多任务带来的好处是明显的,比如你可以边听mp3边上网,与此同时甚至可以将下载的文档打印出来,而这些任务之间丝毫不会相互干扰。如果这些任务同时在一个核上运行,这就是并发,如果这些任务分配到了多个核心,这就是并行

如何实现并发?详情参考《操作系统》,可以粗暴的理解为利用系统中断,根据优先级确定先执行谁。执行A,中断一下去执行B,执行一会儿B,在中断一下返回去执行A,如此循环往复。

并行运行的效率显然高于并发运行,所以在多CPU的计算机中,多任务的效率比较高。同理,如果在多CPU计算机中只运行一个进程(线程),就不能发挥多CPU的优势。

多线程

在计算机编程中,一个基本的概念就是同时对多个任务加以控制。许多程序设计问题都要求程序能够停下手头的工作,改为处理其他一些问题,再返回主进程。最开始的时候,那些掌握机器低级语言的程序员编写一些中断服务例程,主进程的暂停是通过硬件级的中断实现的。

尽管这是一种有用的方法,但编出的程序很难移植,由此造成了另一类的代价高昂问题。中断对那些实时性很强的任务来说是很有必要的。

但对于其他许多问题,只要求将问题划分进入独立运行的程序片断中,程序在逻辑意义上被分割为数个线程,并将数个线程分配给多个核,使整个程序能更迅速地响应用户的请求。多线程是为了同步完成多项任务,即在同一时间需要完成多项任务的时候实现的。假如操作系统本身支持多个处理器,那么每个线程都可分配给一个不同的处理器,真正进入并行运算状态。

从程序设计语言的角度看,多线程操作最有价值的特性之一就是程序员不必关心到底使用了多少个处理器(处理器不够可以并发),可以创建100个线程一起来嘛。

多进程编程

在了解了那么多的基础知识后,先开始进程的编程吧。

进程不共享数据

可以考虑python的multiprocessing库来实现多进程。众所周知,进程之间是不共享数据的,那么一探究竟。本例程的任务是,创建两个进程实现两个计算任务,完成计算任务1后才能完成计算任务2,这是明显的循序

首先,创建了两个子进程p和q,子进程p执行sum1函数,子进程q执行sum2函数。start()方法表示进程开始,join()方法表示等待进程结束。先执行完q进程,修改a和c的值,在执行p进程修改a的值,得到最后a的结果。

from multiprocessing import Process
import os

a, b, c, d = 1, 2, 3, 4

# 子进程要执行的代码
def sum1(name):
global a, b, c
print('Run child process %s (%s)...' % (name, os.getpid()))
a = a + 1 + c
print(a)

def sum2(name):
global a, c, d
print('Run child process %s (%s)...' % (name, os.getpid()))
a = a + 2
c = c + d

if __name__=='__main__':
print('Parent process %s.' % os.getpid())
p = Process(target=sum1, args=('a + b',))
q = Process(target=sum2, args=('c + d',))
print('Child process will start.')
p.start()
q.start()
q.join()
p.join()
print('Child process end.')
print(a)

执行结果输出如下:

Parent process 23843.
Child process will start.
Run child process a + b (23844)...
5
Run child process c + d (23845)...
Child process end.
1

最后一条的数据结果表明现在的a是1,即程序的主进程和创建的两个子进程之间不共享数据,证明了结论。即多进程中,同一个变量,各自有一份拷贝存在于每个进程中,互不影响。那如果就想让进程之间共享数据该如何操作呢?

进程共享数据

只需要特殊的声明变量即可,以下是数值类型的变量:

from multiprocessing import Process
import os, multiprocessing

a = multiprocessing.Value("d", 1)
b = multiprocessing.Value("d", 2)
c = multiprocessing.Value("d", 3)
d = multiprocessing.Value("d", 4)

# 子进程要执行的代码
def sum1(name):
global a, b, c
print('Run child process %s (%s)...' % (name, os.getpid()))
a.value = a.value + 1 + c.value

def sum2(name):
global a, c, d
print('Run child process %s (%s)...' % (name, os.getpid()))
a.value = a.value + 2
c.value = c.value + d.value

if __name__=='__main__':
print('Parent process %s.' % os.getpid())
p = Process(target=sum1, args=('a + b',))
q = Process(target=sum2, args=('c + d',))
print('Child process will start.')
p.start()
q.start()
q.join()
p.join()
print('Child process end.')
print(a.value)

输出结果如下,a的值是7,表明此时多进程以共享数据。

Parent process 23949.
Child process will start.
Run child process a + b (23950)...
Run child process c + d (23951)...
Child process end.
7.0

顺手给出其他类型的数据共享的方法:

  • 数组型:num=multiprocessing.Array("i",[1,2,3,4,5])
  • 字典:mydict=multiprocessing.Manager().dict()
  • 列表:mylist=multiprocessing.Manager().list(range(5))

多进程性能记录

在前文提到,每个进程有一个主线程,两个进程便有两个主线程。将两个线程分配给两个CPU核心,有利于提升程序的执行效率,那么来一探究竟。

这也要求了函数是平行的,即函数的执行没有明显的顺序关系。首先,创建两个进程,在创建两个函数。分别对比顺序执行两个函数和用两个进程执行两个函数的时间,以此来观察多进程是否提升了效率。其中计时函数利用python的time.time()方法,且只记录函数的执行时间。

顺序执行两个函数

import time

a, b, c, d = 1, 2, 3, 4

def sum1():
global a, b, c
a = a + 1 + c

def sum2():
global a, c, d
a = a + 2
c = c + d

if __name__=='__main__':
start = time.time()
sum1()
sum2()
end = time.time()
print(end - start)
print(a)

两个进程分别执行

from multiprocessing import Process
import os, multiprocessing
import time

a, b, c, d = 1, 2, 3, 4

# 子进程要执行的代码
def sum1(name):
global a, c
a = a + 1 + c

def sum2(name):
global a, c, d
a = a + 2
c = c + d

if __name__=='__main__':
print('Parent process %s.' % os.getpid())
p = Process(target=sum1, args=('a + b',))
q = Process(target=sum2, args=('c + d',))
print('Child process will start.')
start = time.time()
p.start()
q.start()
q.join()
p.join()
end = time.time()
print('Child process end.')
print(end - start)

这两个程序不用对比了,顺序执行的比进程执行的要块的多,顺序执行大约0.00000009秒,线程执行大约0.002秒,根本不在一个数量级。

修正

也许你看到这里会疑问,为什么顺序执行会比多进程要快,多进程明明把两个进程分配给了两个核心,并行执行为啥还慢了好几个数量级?

因为,进程的创建、销毁也有额外的开销啊~当开销时间远远比执行时间少的时候,才能体现多进程的优势。既然这样,就额外增加程序的执行时间即可,让两个函数都延时两秒看看。

延时的顺序执行

通过time.sleep(2)方法给每个函数增加两秒的延时:

import time

a, b, c, d = 1, 2, 3, 4

def sum1():
global a, b, c
a = a + 1 + c
time.sleep(2)

def sum2():
global a, c, d
a = a + 2
c = c + d
time.sleep(2)

if __name__=='__main__':
start = time.time()
sum1()
sum2()
end = time.time()
print(end - start)
print(a)

延时的多进程

通过time.sleep(2)方法给每个函数增加两秒的延时:

from multiprocessing import Process
import os, multiprocessing
import time

a, b, c, d = 1, 2, 3, 4

# 子进程要执行的代码
def sum1(name):
global a, c
a = a + 1 + c
time.sleep(2)

def sum2(name):
global a, c, d
a = a + 2
c = c + d
time.sleep(2)

if __name__=='__main__':
print('Parent process %s.' % os.getpid())
p = Process(target=sum1, args=('a + b',))
q = Process(target=sum2, args=('c + d',))
print('Child process will start.')
start = time.time()
p.start()
q.start()
q.join()
p.join()
end = time.time()
print('Child process end.')
print(end - start)

我这里的对比结果是:顺序执行的需要4秒,而多进程仅需2秒。证明了多进程的确能有效提高程序的执行效率。

进程池

可以使用进程池实现对每个进程的管理,即:决定哪些进程可以在进程池内,等待进程池内的所有进程执行完毕才执行下一部分的程序。粗暴的理解:一个大池子,这个池子里有好多进程,通过这个池子实现对进程的统一管理。

为了体现进程的并发,我特意在12个核的CPU内创建了13个进程。意思是:会有一个进程要等待其他进程释放一个CPU资源才能执行。进程池的close()方法会阻止其他进程在进入进程池,join()方法会等待进程池全部的进程执行完毕。

创建13个进程,并使每个进程等待2秒观看执行效果:

from multiprocessing import Pool
import os, time

def long_time_task(name):
print('Run task %s (%s)...' % (name, os.getpid()))
start = time.time()
time.sleep(2)
end = time.time()
print('Task %s runs %0.2f seconds.' % (name, (end - start)))

if __name__=='__main__':
print('Parent process %s.' % os.getpid())
p = Pool(13)
for i in range(14):
p.apply_async(long_time_task, args=(i,))
print('Waiting for all subprocesses done...')
# 不能在增加其他进程
p.close()
p.join()
print('All subprocesses done.')

可以看到,第13个进程很晚才创建,在3, 2, 1, 12, 0, 10, 4, 7, 11, 8这几个进程执行完毕后,第13个进程才刚刚创建。

在这13个进程中,每个进程都有一个主线程。CPU给线程分配时间片(也就是分配给线程的时间),执行完时间片后会切换都另一个线程。从保存线程A的状态到准备切换为线程B时,加载线程B的状态的这个过程就叫上下文切换,而上下切换时会消耗大量的CPU时间。因此,只有在特定的条件下开启多线程才会更合适。不可避免的线程开销包括:

  • 上下文切换消耗

  • 线程创建和消亡的开销

  • 线程需要保存维持线程本地栈,会消耗内存

对于计算密集型的程序,程序主要为复杂的逻辑判断和复杂的运算,此时CPU的利用率高,不用开太多的线程,开太多线程反而会因为线程切换时切换上下文而浪费资源。计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要。Python这样的脚本语言运行效率很低,完全不适合计算密集型任务。对于计算密集型任务,最好用C语言编写。

对于IO密集型的程序,比如磁盘IO(读取文件)和网络IO(网络请求)。因为IO操作会阻塞线程,CPU利用率不高,可以开多点线程,一个线程阻塞时(网络延迟等)可以切换到其他就绪线程,提高CPU的利用率。这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。IO密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少,因此,用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率。对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C语言最差。

进程通信

举个通俗但不严谨的例子:在输入法开启英文模式并向记事本中打字时,一边打字一边显示,这就涉及了打字进程和记事本的显示进程。记事本的显示进程一直读取打字进程的打字结果,讲打字结果读取并显示在屏幕上,这就设计了两个进程之间的通信。那么实现一个边写边读的例子:

from multiprocessing import Process, Queue
import os, time, random

# 写数据进程执行的代码:
def write(q):
print('Process to write: %s' % os.getpid())
for value in ['A', 'B', 'C']:
print('Put %s to queue...' % value)
q.put(value)
time.sleep(random.random())

# 读数据进程执行的代码:
def read(q):
print('Process to read: %s' % os.getpid())
while True:
value = q.get(True)
print('Get %s from queue.' % value)

if __name__=='__main__':
# 父进程创建Queue,并传给各个子进程:
q = Queue()
pw = Process(target=write, args=(q,))
pr = Process(target=read, args=(q,))
# 启动子进程pw,写入:
pw.start()
# 启动子进程pr,读取:
pr.start()
# 等待pw结束:
pw.join()
# pr进程里是死循环,无法等待其结束,只能强行终止:
pr.terminate()

多线程编程

假如机器本身安装了多个处理器,那么程序会运行得更快,毋需作出任何特殊的调校。多任务可以由多进程完成,也可以由一个进程内的多线程完成。

创建线程

import time, threading

# 新线程执行的代码:
def loop():
print('thread %s is running...' % threading.current_thread().name)
time.sleep(1)
print('thread %s ended.' % threading.current_thread().name)

print('thread %s is running...' % threading.current_thread().name)
t = threading.Thread(target=loop, name='Thread-1')
t.start()
t.join()
print('thread %s ended.' % threading.current_thread().name)

由于任何进程默认就会启动一个线程,我们把该线程称为主线程,主线程又可以启动新的线程。current_thread()函数,它永远返回当前线程的实例。主线程实例的名字叫MainThread,子线程的名字在创建时指定,我们用Thread-1命名子线程。输出结果如下(最后输出的是主进程的结束):

thread MainThread is running...
thread LoopThread is running...
thread LoopThread ended.
thread MainThread ended.

线程锁

使用多线程必须注意一个问题,多个线程之间共享资源!如果有多个线程同时运行,而且它们试图访问相同的资源,就会遇到各种问题。举个例子来说,两个线程不能同时读写文件,以最常见的$\LaTeX$为例,除summtrapdf外,其他pdf阅读器在观看pdf文件时,不能进行编译,在编译的时候要提前关闭打开的pdf。即一个线程要读,一个线程要写,读线程占据着资源,写线程得不到资源,自然就报错了。

多线程中,变量都由所有线程共享。所以,任何一个变量都可以被任何一个线程修改。如修改一个变量需要多条语句,在执行这几条语句时,线程可能中断,很可能把内容给改乱了。如下所示的代码,创建一个为0的变量,创建两个线程,并反复执行+1和-1的操作,最后的结果不一定是0。

balance = 0

def change_it(n):
# 先存后取,结果应该为0:
global balance
balance = balance + n
balance = balance - n

def run_thread(n):
for i in range(10000000):
change_it(n)

t1 = threading.Thread(target=run_thread, args=(1,))
t2 = threading.Thread(target=run_thread, args=(1,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)

输出的结果是2。为什么呢?因为两个线程的调度是由操作系统决定的,是交替执行的。第一步计算balance + 1,存入临时变量中,第二步将临时变量的值赋给balance。那么执行顺序是这样时:(x1 和 x2 是临时变量)

初始值 balance = 0

t1: x1 = balance + 1 # x1 = 0 + 1 = 1
t2: x2 = balance + 1 # x2 = 0 + 1 = 1

t2: balance = x2 # balance = 1
t1: balance = x1 # balance = 1

t1: x1 = balance - 1 # x1 = 1 - 1 = 0
t1: balance = x1 # balance = 0

t2: x2 = balance - 1 # x2 = 0 - 1 = -1
t2: balance = x2 # balance = -1

结果 balance = -1,不再是脑补出来的0。因此,线程之间共享数据最大的危险在于多个线程同时改一个变量。你也不想银行系统里改着改着你的存款成了负数,或者结婚系统里你的老婆成了别人的老婆,滑稽。

为了保证变量的正确,务必要给操作函数上一把锁子,姑且把操作的函数成为change_it()。当某个线程开始执行change_it()时,我们说,该线程因为获得了锁,因此其他线程不能同时执行change_it(),只能等待。直到锁被释放后,其他线程获得该锁以后才能继续操作。由于锁只有一个,无论多少线程,同一时刻最多只有一个线程持有该锁,所以,不会造成修改的冲突。创建一个锁就是通过threading.Lock()来实现:

balance = 0
lock = threading.Lock()

def run_thread(n):
for i in range(100000):
# 先要获取锁:
lock.acquire()
try:
# 放心地改吧:
change_it(n)
finally:
# 改完了一定要释放锁:
lock.release()

当多个线程同时执行lock.acquire()时,只有一个线程能成功地获取锁,然后继续执行代码,其他线程就继续等待锁释放,直到获得锁为止。获得锁的线程用完后一定要释放锁,否则那些苦苦等待锁的线程将永远等待下去,成为死线程。所以我们用try…finally来确保锁一定会被释放。

锁的好处就是确保了某段关键代码只能由一个线程从头到尾完整地执行,坏处当然也很多。首先是程序操作临界资源时,阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了。临界资源举例:如打印机,同一时刻只能由一个线程控制打印,如写文件,同一时刻只能有一个线程在写。

其次,由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁。如线程A等待线程B释放资源C,线程B要等待线程A释放资源D,两者均需要对方先释放,于是两者相互等待,无限套娃。在详细一点举个不恰当的例子,一个绑架犯和被绑架的亲属,绑架犯说:不给钱就不放人,绑架犯亲属说:不放人就不给钱。对于绑架犯而言,人质是获取的锁,想要获取钱这个锁;对于绑架犯亲属而言,钱是获取的锁,想要获取人质这个锁。但双方都不先松口,即不释放自己的资源,于是无限套娃,造成线程死锁,多个线程全部挂起,既不能执行,也无法结束,只能靠操作系统强制终止。(操作系统有一套判断死锁的方法,详情参考《操作系统》相关书籍),所以需要避免线程的死锁。

多核线程

如果你不幸拥有一个多核CPU,你肯定在想,多核应该可以同时执行多个线程。如果写一个死循环的话,会出现什么情况呢?我们可以监控到一个死循环线程会100%占用一个CPU。

如果有两个死循环线程,在多核CPU中,可以监控到会占用200%的CPU,也就是占用两个CPU核心。要想把N核CPU的核心全部跑满,就必须启动N个死循环线程。

但python启动与CPU核心数量相同的N个线程跑死循环,在4核CPU上可以监控到CPU占用率仅有102%,也就是仅使用了一核。但是用C、C++或Java来改写相同的死循环,直接可以把全部核心跑满,4核就跑到400%,8核就跑到800%,为什么Python不行呢?

因为Python的线程虽然是真正的线程,但解释器执行代码时,有一个GIL锁:Global Interpreter Lock。任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。

GIL最大的问题就是Python的多线程程序并不能利用多核CPU的优势 。在讨论普通的GIL之前,有一点要强调的是GIL只会影响到那些严重依赖CPU的程序(比如计算型的)。 如果你的程序大部分只会涉及到I/O,比如网络交互,那么使用多线程就很合适, 因为它们大部分时间都在等待。实际上,你完全可以放心的创建几千个Python线程, 现代操作系统运行这么多线程没有任何压力,没啥可担心的。

GIL是Python解释器设计的历史遗留问题,通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。所以,在Python中,可以使用多线程,但不要指望能有效利用多核。不过,也不用过于担心,Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。

线程的变量

不同的线程调用函数,且需要处理一些局部变量时,也可以写成这样不断的传参,只是很麻烦罢了:

def process_student(name):
std = Student(name)
# std是局部变量,但是每个函数都要用它,因此必须传进去:
do_task_1(std)
do_task_2(std)

def do_task_1(std):
do_subtask_1(std)
do_subtask_2(std)

def do_task_2(std):
do_subtask_2(std)
do_subtask_2(std)

在多线程环境下,每个线程都有自己的数据。一个线程使用自己的局部变量比使用全局变量好(不是程序中的局部变量和全局变量,是线程里的局部变量和全局变量)。因为线程的局部变量只有线程自己能看见,不会影响其他线程。可以通过ThreadLocal实现避免局部变量的层层传递的问题:

import threading

# 创建全局ThreadLocal对象:
local_school = threading.local()

def process_student():
# 获取当前线程关联的student:
std = local_school.student
print('Hello, %s in %s' % (std, threading.current_thread().name))

def process_thread(string):
# 绑定ThreadLocal的student:
local_school.student = string
process_student()

t1 = threading.Thread(target = process_thread, args=('Alice',), name='Thread-A')
t2 = threading.Thread(target = process_thread, args=('Bob',), name='Thread-B')
t1.start()
t2.start()
t1.join()
t2.join()

输出结果如下:

Hello, Alice in Thread-A
Hello, Bob in Thread-B

全局变量local_school就是一个ThreadLocal对象,每个Thread对它都可以读写student属性,但每个线程都只能读写自己线程的独立副本,互不影响。你可以把local_school看成全局变量,但每个属性如local_school.student都是线程的局部变量,可以任意读写而互不干扰,也不用管理锁的问题。ThreadLocal解决了参数在一个线程中各个函数之间互相传递的问题。

总结

我们介绍了多进程和多线程,这是实现多任务最常用的两种方式。现在,我们来讨论一下这两种方式的优缺点。

多进程模式最大的优点就是稳定性高,因为一个子进程崩溃了,不会影响主进程和其他子进程。(当然主进程挂了所有进程就全挂了,但是Master进程只负责分配任务,挂掉的概率低)。

多进程模式的缺点是创建进程的代价大,在Unix/Linux系统下,用fork调用还行,在Windows下创建进程开销巨大。

多线程模式致命的缺点就是任何一个线程挂掉都可能直接造成整个进程崩溃,因为所有线程共享进程的内存。在Windows上,如果一个线程执行的代码出了问题,你经常可以看到这样的提示:“该程序执行了非法操作,即将关闭”,其实往往是某个线程出了问题,但是操作系统会强制结束整个进程。

无论是多进程还是多线程,只要数量一多,效率肯定上不去,为什么呢?

因为进程或线程的切换是有代价的,操作系统在切换进程或者线程时需要先保存当前执行的现场环境(CPU寄存器状态、内存页等),然后,把新任务的执行环境准备好(恢复上次的寄存器状态,切换内存页等),才能开始执行。这个切换过程虽然很快,但是也需要耗费时间。如果有几千个任务同时进行,操作系统可能就主要忙着切换任务,根本没有多少时间去执行任务了,这种情况最常见的就是硬盘狂响,点窗口无反应,系统处于假死状态。

所以,多任务一旦多到一个限度,就会消耗掉系统所有的资源,结果效率急剧下降,所有任务都做不好。

结语

为下文的异步爬虫做准备。考虑到CPU和IO之间巨大的速度差异,一个任务在执行的过程中大部分时间都在等待IO操作,单进程单线程模型会导致别的任务无法并行执行,因此,我们才需要多进程模型或者多线程模型来支持多任务并发执行。

现代操作系统对IO操作已经做了巨大的改进,最大的特点就是支持异步IO。如果充分利用操作系统提供的异步IO支持,就可以用单进程单线程模型来执行多任务,这种全新的模型称为事件驱动模型,在单核CPU上采用单进程模型就可以高效地支持多任务。在多核CPU上,可以运行多个进程(数量与CPU核心数相同),充分利用多核CPU。由于系统总的进程数量十分有限,因此操作系统调度非常高效。

用异步IO编程模型来实现多任务是一个主要的趋势。对应到Python语言,单线程的异步编程模型称为协程,有了协程的支持,就可以基于事件驱动编写高效的多任务程序,我会在后面讨论如何编写协程。

至此,通过本文的学习我了解了三种实现多任务的方式:(没错我也是刚学,本文只是学习内容的整理)

  • 多进程
  • 多线程并发
  • 协称

会在后文实际对比这三种方法的效率,敬请期待。

参考

站在巨人的肩膀上,我们能前行的更快。

https://blog.csdn.net/zhengyshan/article/details/80641770

https://blog.csdn.net/SSIrreplaceable/article/details/53171785

https://www.liaoxuefeng.com/wiki/1016959663602400/1017629247922688

明人不说暗话,如果你感觉我写的还可以对你有帮助的话,Could you buy me a yogurt? ,文末也可评分。如果这样不行的话,我,秦始皇,打钱。

欢迎订阅我的文章