文章目录
进程与线程的简介
首先说下并发和并行的区别。
- 并发(concurrency)。操作系统轮流让单个CPU执行各个任务,即某一时刻该CPU只能执行一个任务,只是由于CPU在任务之间来回切换的时间很少让用户感知不到,所以感觉是同时执行多个任务。
- 并行(parallelism)。真正的并行任务只能在多核CPU上实现,每个任务一个CPU,所以某一时刻会存在多个任务同时执行,但是由于任务的数量远远多于CPU数,所以CPU还是会在任务之间切换。
如下图所示,我本机电脑就是8核逻辑CPU,虽然物理核只有4个,但是由于是一个CPU两个超线程所以就是4*2=8核(至于为什么一个CPU有两个超线程,是反着推算的8/4=2)
对于Linux机器来说,可以通过cat /proc/cpuinfo| grep "processor"| wc -l
来计算出机器的逻辑CPU核数。
对于Python来说,可以采用多进程+多线程
的方式来实现多任务的同时处理,但是多进程和多线程的程序涉及到同步、数据共享等问题,编写起来相当复杂。用操作系统的话来定义进程和线程。
进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位,这里的资源是CPU、内存等资源。
线程是独立调度的基本单位,进程是拥有资源的基本单位。
进程只作为除CPU以外系统资源的分配单元,线程则作为处理机即CPU的分配单元。
多进程
Python内置了multiprocessing
模块提供跨平台的多进程实现。
Process
类multiprocessing.Process
是一个类,它的初始化方法中target
参数表示进程需要运行的任务,args
参数表示传给任务的参数。
p.start()
是启动进程
p.join([timeout])
会阻塞调用该方法的进程直到进程终止或到达给定的超时时间(单位秒),不能通过该方法判定是否进程终止,应该通过p.exitcode
去判断。不能自己调用自己的join()
方法,会造成死循环。
p.exitcode
获取进程的退出码。如果进程未终止则返回None
。否则A negative value -N indicates that the child was terminated by signal N.
import os
from multiprocessing import Process,Pool
def run_proc_func(name):
# time.sleep(7)
print("子进程正在运行,参数name={}\tpid={}".format(name, os.getpid()))
def test():
print("父进程id是 {}".format(os.getpid()))
# 创建进程
p = Process(target=run_proc_func, args=("Hello",))
print("子进程即将运行")
# 启动进程
p.start()
# 等待子进程 用于进程间的同步
p.join(4)
print("子进程已经运行完")
Pool
类multiprocessing.Pool
是一个类,可以以进程池的方式创建大量子进程。
需要说明一点的是,multiprocessing.Pool
初始化时默认进程池大小就是CPU逻辑核数os.cpu_count()
pool.apply_async(func[, args[, kwds[, callback[, error_callback]]]])
是异步启动执行参数func
指定的函数,其中args
和kwds
都是传给指定函数的参数,callback
和error_callback
是任务运行成功和失败的回调函数,回调函数的参数必须是一个。
pool.close()
是阻止向进程池提交任务
pool.terminate()
是立即停止工作进程
pool.join()
是等待所有工作进程退出,在执行该方法前必须执行pool.close()
或者pool.terminate()
import os
from multiprocessing import Process,Pool
import time
import random
def long_time_task(name):
print("Task {} is running in pid={}".format(name, os.getpid()))
start = time.perf_counter()
time.sleep(random.randint(2,5))
end = time.perf_counter()
spend_time = end-start
print("Task {} runs time {} seconds".format(name, spend_time))
return os.getpid(),name,spend_time
def callback(res):
pid = res[0]
taskid = res[1]
spend_time = res[2]
print("这是Taskid={}\tpid={}进程的回调方法,其花费时间是{}".format(taskid,pid,spend_time))
def test_pool():
print("父进程id是 {}".format(os.getpid()))
# 进程池的大小默认就是cpu的逻辑核数 os.cpu_count()
pool = Pool()
for i in range(5):
pool.apply_async(long_time_task, (i,), callback=callback)
print("waiting for all subprocess done...")
# 调用join前必须调用close. 调用close后保证后续无法添加新的进程
pool.close()
pool.join()
print("all subprocess done")
if __name__ == "__main__":
test_pool()
subprocess
subprocess
模块可以让我们创建一个新的进程,并控制其输入和输出及返回码。
详细的使用方法可参考python官网----subprocess。
在3.5版本及以后,建议使用subprocess.run()
来处理它能够处理的情况,更加复杂的情况可以使用底层的subprocess.Popen()
类来处理。
subprocess.run(args, *, stdin=None, stdout=None, stderr=None, shell=False,
timeout=None, check=False, encoding=None)
执行args
所描述的命令,会一直等待命令完成然后返回一个subprocess.CompletedProcess
对象。
stdin/stdout/stderr
确定了该执行程序的标准输入/标准输出/错误
的文件句柄。这里的文件句柄支持subprocess.PIPE
、subprocess.DEVNULL
以及已存在的文件描述符。subprocess.PIPE
可理解为一个缓存区。
shell=True
表示使用使用/bin/sh
,当为True时,args
最好是一个字符串,如果是False,args
可表示为序列。建议使用shell=True
。
timeout
表示命令执行的超时时间
check=True
表示当程序的退出码非0时就会抛出CalledProcessError
错误。
encoding
设置后那么标准输入/标准输出/错误
的文件句柄会以文本模式打开,否则会以二进制模式打开。
import subprocess
cp = subprocess.run("python -V", stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
print(cp.returncode)
cp = subprocess.run("python -V", stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf8", shell=True)
print(cp.stdout)
通过执行args对应的命令获取当前python版本信息,并且通过设置stdout=subprocess.PIPE, stderr=subprocess.PIPE
来让返回的CompletedProcess
对象来具有命令的标准输出信息和错误信息。这里是不能设置stdin
的,因为run()
是立即执行的。
注意下面的截图信息,当我没有设置encoding
时stdout
返回的是二进制数据,但是当我设置encoding="utf8"
时返回的就是文本信息了,如果输出的是中文就会看的更清晰,不过输出中文时注意编码格式。
在Python3.5版本以前是通过以下3个高级API来执行命令的。
subprocess.call(args, *, stdin=None, stdout=None, stderr=None,
shell=False, timeout=None)
该方法的返回值就是returncode
,是无法获取标准输出的,除非标准输出设置为文件描述符即将输出信息写入到文件。等价于run(...).returncode
。
注意下面的例子中如果换成第一个cmd可能会报错UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb4 in position 0: invalid
,这是系统编码引起的,不在本文讨论范围之内,毕竟编码真的是一个很庞大的东西。我的第二个cmd中的utf8文件编码就是utf8格式,所以第二个cmd就能成功运行。
所以啊最好是全英文输出,这样就能避免编码问题。
# -*- coding: UTF-8 -*-
import sys
import subprocess
def read_content(fpath):
with open(fpath, mode="r", encoding="utf8") as f:
return f.read()
cmd = "echo 大数据"
cmd = "cat utf8"
f = open("log", mode="w", encoding="utf8")
rtc = subprocess.call(cmd, shell=True, stdout=f)
f.close()
print(read_content("log"))
f = open("log1", mode="w", encoding="utf8")
cp = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, encoding="utf8")
print(cp.stdout)
f.write(cp.stdout)
f.close()
看下面这个例子,命令"echo 大数据"
输出的其实是GBK格式(这和系统编码有关),如果命令是cat utf8
那么就没问题了。所以run()
方法里的参数encoding
其实最终是将bytes编码转码成对应encoding格式的字符,所以其实这里是一个解码的过程。
subprocess.check_call(args, *, stdin=None, stdout=None, stderr=None,
shell=False, timeout=None)
等待命令执行,当退出码不是0时会报错CalledProcessError
。等价于run(..., check=True)
try:
rtc = subprocess.check_call("echo1 abc", shell=True)
except subprocess.CalledProcessError as e:
print("执行命令是{}".format(e.cmd))
print("返回码是{}".format(e.returncode))
subprocess.check_output(args, *, stdin=None, stdout=None, stderr=None,
shell=False, timeout=None)
等待命令执行。当退出码不是0时会报错CalledProcessError
,等于0时会返回输出信息。等价于run(..., check=True, stdout=PIPE).stdout
。
try:
# utf8是编码格式为utf8的文件
stdout = subprocess.check_output("cat utf8", shell=True, encoding="utf8")
print("命令输出是{}".format(stdout))
except subprocess.CalledProcessError as e:
print("执行命令是{}".format(e.cmd))
print("返回码是{}".format(e.returncode))
更高级的用法,采用底层subprocess.Popen
类的方式。
其初始化参数如下所示。
常用的方法如下
方法 | 描述 |
---|---|
Popen.poll() | 用于检查子进程(命令)是否已经执行结束,没结束返回None,结束后返回状态码。 |
Popen.wait(timeout=None) | 等待子进程结束,并返回状态码;如果在timeout指定的秒数之后进程还没有结束,将会抛出一个TimeoutExpired异常。 |
Popen.communicate(input=None, timeout=None) | 该方法可用来与进程进行交互,比如发送数据到stdin,从stdout和stderr读取数据,直到到达文件末尾。既可以通过input发送数据,也可以通过stdin发送数据,但是后者需要设置stdin=subprocess.PIPE |
Popen.pid | 子进程的id。 |
Popen.terminate() | 停止该子进程。 |
Popen.kill() | 杀死该子进程。 |
cmd = "python"
# 如果universal_newlines参数值为True,则input参数的数据类型必须是字符串
# 否则应该是bytes
p = subprocess.Popen(cmd, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
p.stdin.write("import sys\n")
p.stdin.write("print(sys.version)\n")
stdout,stderr = p.communicate("print(sys.version)\n")
下面是一个实时获取日志输出的例子。
假如test.sh
脚本文件内容如下
#!/bin/bash
i=1
while (( $i <= 10 ))
do
content=`date`
echo "现在是"$content
let i+=1
sleep 1
done
实时获取日志输出代码如下所示。之所以写with语句是为了保证子进程的输入输出流能自动关闭。
with subprocess.Popen("sh test.sh", shell=True, stdout=subprocess.PIPE, encoding="utf8") as p:
while p.poll() is None:
output = p.stdout.readline()
output = output.strip()
if output:
print(output)
print(p.stdout.closed)
print("退出码是{}".format(p.returncode))
进程通信
进程间的通信可以使用multiprocessing.Queue
,注意这里是进程间的Queue,这个队列是进程安全和线程安全的。更加详细的信息可参考官网关于进程间的信息交换。
from multiprocessing import Queue,Process
import os,time,random
def write(q:Queue):
print("Process to write: {}".format(os.getpid()))
for c in "ABCD":
print("Put {} into queue.".format(c))
q.put(c)
time.sleep(random.randint(1,4))
def read(q:Queue):
print("Process to read: {}".format(os.getpid()))
while True:
c = q.get()
print("Get {} from queue.".format(c))
def test_queue():
q = Queue()
w = Process(target=write, args=(q,))
r = Process(target=read, args=(q,))
# 启动读写进程
w.start()
r.start()
# 等待写进程全部执行完
w.join()
while True:
if q.empty():
r.terminate()
break
print("queue is not empty")
多线程
实现线程的两种方式
Python内置了threading
模块来提供多线程功能。
类threading.Thread
的初始化函数如下,其中
class threading.Thread(group=None, target=None, name=None,
args=(), kwargs={}, *, daemon=None)
target
就是可执行的函数
name
就是线程名,不传的话默认是Thread-N
args
和kwargs
是传入到可执行函数的参数
实现Python有两种方法,一种方法就是使用类threading.Thread
,还一种就是继承类threading.Thread
,不过继承时需要注意在初始化方法里一定要调用父类的构造方法,且需要重写run()
方法
import threading
import time
import random
def thread_task(id):
current_thread_name = threading.current_thread().name
print("当前线程名是 {} , 任务参数id={}".format(current_thread_name, id))
print("线程{}正在执行任务......".format(current_thread_name))
time.sleep(5)
print("线程{}已经执行完任务......".format(current_thread_name))
def test_thread():
current_thread_name = threading.current_thread().name
print("当前线程是{} 开始运行..".format(current_thread_name))
t = threading.Thread(target=thread_task, args=(110,), name="thread-task")
t.start()
t.join()
log_task = LogTaskThread(10)
log_task.start()
log_task.join()
# MainThread
print("当前线程是{} 运行结束".format(current_thread_name))
class LogTaskThread(threading.Thread):
def __init__(self, size):
self.size = size
threading.Thread.__init__(self,name="logTaskThread")
def run(self):
ctn = threading.current_thread().name
print("当前线程{}重写了父类的run()方法".format(ctn))
print("线程{}正在处理大小为{}GB的日志清理工作...".format(ctn, self.size))
time.sleep(random.randint(5,10))
print("线程{}已经完成日志的清理工作".format(ctn))
if __name__ == "__main__":
test_thread()
线程安全
在Java里多线程有线程安全问题,在Python线程里一样也存在。
线程安全的发生在于共享(临界)资源的竞争,譬如下面的例子,两个线程同时对全局变量balance
做修改就会发生线程安全的问题。
可以采用threading.Lock
锁将对共享资源进行修改的操作给锁起来,但是特别注意的是需要手动释放锁否则会造成死锁。下面的例子展示了非线程安全和用锁让其变成线程安全的情况。
锁虽然解决了线程安全问题,但是同样地它也失去了多线程的并发性。
import threading
balance = 0
mylock = threading.Lock()
def change_balance(n):
global balance
balance = balance + n
balance = balance - n
def run_task(n):
for i in range(10000000):
# 线程安全
mylock.acquire()
try:
change_balance(n)
finally:
mylock.release()
# 非线程安全
# change_balance(n)
def test_thread_safe():
t1 = threading.Thread(target=run_task, args=(5,))
t2 = threading.Thread(target=run_task, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print("balance={}".format(balance))
if __name__ == '__main__':
test_thread_safe()
多核CPU
运行如下代码
import threading, multiprocessing
def loop():
x = 0
while True:
x = x ^ 1
if __name__=='__main__':
for i in range(multiprocessing.cpu_count()):
t = threading.Thread(target=loop)
t.start()
如下图,启动与核数相同的线程并没有把CPU跑到100%。
但是Java多线程能把所有的CPU跑满。
public static void main(String[] args) throws IOException {
Runnable a = ()->{
while (true){
}
};
for(int i=0; i<8; ++i){
new Thread(a).start();
}
}
那么为什么Java的线程能把CPU跑满,而Python的线程却不能呢?
因为Python的线程虽然是真正的线程,但解释器执行代码时,有一个GIL锁:Global Interpreter Lock,任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。
不过,也不用过于担心,Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。
import threading, multiprocessing
from multiprocessing import Process
def loop():
x = 0
while True:
x = x ^ 1
if __name__=='__main__':
for i in range(multiprocessing.cpu_count()):
t = Process(target=loop)
t.start()
ThreadLocal对象
Java的数据库连接框架如mybatis就是基于ThreadLocal
来保证每个连接的独立和线程安全。Python这里的ThreadLocal
对象也是类似的。ThreadLocal
对象一般用于多线程中,保证每个线程都有自己独立的变量副本并且可读可修改。
通过threading.local()
来创建ThreadLocal
对象,然后分别在线程里使用。ThreadLocal
对象解决了参数在一个线程中各个函数之间互相传递的问题。
如下展示了不同用户登录会话的示例
# -*- coding: UTF-8 -*-
import threading
threadlocal = threading.local()
def set_session(name):
# 为当前线程设置登录名和对应的账户余额(默认100)
threadlocal.name = name
threadlocal.balance = 100
process_session()
def process_session():
session_name = threadlocal.name
ctn = threading.current_thread().name
if session_name == "admin":
print("线程 {} 对应的会话中,登录用户属于管理员,其用户名是 {}".format(ctn, session_name))
threadlocal.balance = threadlocal.balance + 50
print("线程 {} 对应的会话中,给管理员账户 {} 添加50元".format(ctn, session_name))
else:
print("线程 {} 对应的会话中,登录用户属于普通用户,其用户名是 {}".format(ctn, session_name))
threadlocal.balance = threadlocal.balance - 50
print("线程 {} 对应的会话中,给普通账户 {} 扣除50元".format(ctn, session_name))
print_session()
def print_session():
ctn = threading.current_thread().name
print("线程 {} 对应的会话中,账户信息是 {}".format(ctn, threadlocal.__dict__))
def test_threadlocal():
t1 = threading.Thread(target=set_session, args=("admin",))
t2 = threading.Thread(target=set_session, args=("patrick",))
t1.start()
t2.start()
t1.join()
t2.join()
print("线程全部执行完毕")
if __name__=='__main__':
test_threadlocal()
线程 VS 进程
多进程模式最大的优点就是稳定性高。一个子进程崩溃了,不会影响主进程和其他子进程。其缺点是创建进程的代价大,在内存和CPU的限制下,如果有几千个进程同时运行,操作系统连调度都会成问题。
多线程模式通常比多进程快一点,线程模式致命的缺点就是任何一个线程挂掉都可能直接造成整个进程崩溃,因为所有线程共享进程的内存。
线程切换
(注:廖雪峰老师这段讲的太好,就忍不住拷贝过来了!)
无论是多进程还是多线程,只要数量一多,效率肯定上不去,为什么呢?
我们打个比方,假设你不幸正在准备中考,每天晚上需要做语文、数学、英语、物理、化学这5科的作业,每项作业耗时1小时。
如果你先花1小时做语文作业,做完了,再花1小时做数学作业,这样,依次全部做完,一共花5小时,这种方式称为单任务模型,或者批处理任务模型。
假设你打算切换到多任务模型,可以先做1分钟语文,再切换到数学作业,做1分钟,再切换到英语,以此类推,只要切换速度足够快,这种方式就和单核CPU执行多任务是一样的了,以幼儿园小朋友的眼光来看,你就正在同时写5科作业。
但是,切换作业是有代价的,比如从语文切到数学,要先收拾桌子上的语文书本、钢笔(这叫保存现场),然后,打开数学课本、找出圆规直尺(这叫准备新环境),才能开始做数学作业。操作系统在切换进程或者线程时也是一样的,它需要先保存当前执行的现场环境(CPU寄存器状态、内存页等),然后,把新任务的执行环境准备好(恢复上次的寄存器状态,切换内存页等),才能开始执行。这个切换过程虽然很快,但是也需要耗费时间。如果有几千个任务同时进行,操作系统可能就主要忙着切换任务,根本没有多少时间去执行任务了,这种情况最常见的就是硬盘狂响,点窗口无反应,系统处于假死状态。
所以,多任务一旦多到一个限度,就会消耗掉系统所有的资源,结果效率急剧下降,所有任务都做不好。
计算密集型 VS IO密集型
计算密集型任务的特点是要进行大量的计算,消耗CPU资源。但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低。所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要,最好用C语言编写。
涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。IO密集型任务执行期间,花在CPU上的时间很少,因此,用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率,合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选。
异步IO
现代操作系统对IO操作已经做了巨大的改进,最大的特点就是支持异步IO。
如果充分利用操作系统提供的异步IO支持,就可以用单进程单线程模型来执行多任务,这种全新的模型称为事件驱动模型,如Nginx就是支持异步IO的Web服务器。
对应到Python语言,单线程的异步编程模型称为协程。有了协程的支持,就可以基于事件驱动编写高效的多任务程序。
关于协程的部分后面会专门写一篇博客。
分布式进程
Python能很容易实现分布式进程协作任务。后面再补充。