一、进程创建的方式
1、fork
特点:
①直接拷贝当前进程中几乎所有变量的值(注意不是执行主进程中的代码);
②可以在代码中任何位置开启;
③可以自动拷贝文件对象,锁等特殊对象,也可以通过参数传递;
④只能在unix系统上使用, 注意不支持Windows;
⑤启动速度快;
为了说明第一点,通过看下述代码预测一下输出结果:
import multiprocessing
def task():
file_obj.write("alex\n")
file_obj.flush()
if __name__ == "__main__":
multiprocessing.set_start_method("fork")
file_obj = open("temp.txt", "a+", encoding="utf-8")
file_obj.write("武佩齐\n")
file_obj.flush()
p1 = multiprocessing.Process(target=task)
p1.start()
分析:
①主进程开启文件后file_obj.write("武沛齐\n")将数据写入内存,随后使用flush刷入磁盘; 此时磁盘中只有: 武沛齐
②开启新进程,新进程拷贝当前file_obj, 由于file_obj被flush过,此时的file_obj文件缓冲区是没有任何内容的,拷贝到新进程中的file_obj自然文件缓冲区也是没有内容的;
③在新进程中, file_obj.write("alex\n")写入内存,同样执行flush刷入磁盘, 随后子进程结束,主进程在等待子进程结束后最终将file_obj对象关闭。
所以,可以得知,此时file_obj只有两行数据,就是"武沛齐\nalex\n"
2、spawn
特点:
①需要手动传入新进程需要的参数;
②只能在main中执行, 特别解释: 由于spawn开启的进程会自动执行开启进程前的代码, 如果不将开启进程的代码包裹在:
if __name__ == "__main__":
pass
子进程就会误执行代码, 导致错误; 通过将代码包裹在在main中,子进程就不会执行main里的代码,从而保护了主进程的代码。
③与fork的③相反,但是注意,进程间可以传递进程锁;
④支持Windows系统和unix系统;
⑤启动速度慢;
特别为了说明第二点,我当时特地构造过一段代码研究,如下所示:
大家可以自己摸索执行,尝试解释一下为什么执行结果会长这样。
from multiprocessing import Process, set_start_method
import tkinter as tk
def open_new_process():
array = (1, 2, 3)
new_process = Process(target=process_task, args=(array, ))
new_process.start()
def process_task(array: tuple[str | float]):
print("正在处理进程任务......")
return sum(array)
if __name__ == "__main__":
set_start_method("spawn")
root = tk.Tk()
button = tk.Button(root, text="测试多进程按钮", command=open_new_process)
button.place(x=10, y=10)
root.mainloop()
3、forkserver
特点:
这个和fork很像,但是它只能运行在部分unix系统上,并且它开启新进程是基于空模版的; 由于我本人没有安装unix, 而且forkserver在实际项目中理应也极少使用,此处仅作了解。
二、进程间的资源共享, 此时简单介绍,具体用法"用查"
"""
进程间数据共享在multiprocessing中一共提供了四种方法
其中最常用的是①Queue和②Manager
还有③[C->Value+Array]和④Pipe的组合
③->严格遵循C语言对数据类型的定义,创建后的变量可以直接在进程间共享
④两个队列组成的管道, 1.主进程 -> 子进程 的队列 / 2.子进程 -> 主进程 的队列
队列由于其有序性,不会出现资源竞态问题,因此无需加锁
"""
文末有一个小项目,刚好使用了manager, 可以感受一下。
三、进程锁
由于比较懒,这里仅通过解释一段代码说明进程锁:
注意下述代码中,temp.txt当中有且仅有一行数据,里面的数值自定,我本人写的是15
"""
注意点:
①锁的对应:
multiprocessing创建的Process只能由multiprocessing.Lock/Rlock来锁;
concurrent.futures创建的进程池是由multiprocessing.Manager()的实例对象的Lock/Rlock来锁;
②进程锁可以在进程间共享,但线程锁不可以!
"""
import multiprocessing
import os
from time import sleep
def task(_lock: multiprocessing.RLock):
_lock.acquire()
# 此时简单的将文件中的数字作为进程间的共享变量
with open("temp.txt", "r+", encoding="utf-8")as file:
current_ticket_count = int(file.read())
print("排队抢票啦!")
sleep(0.5)
current_ticket_count -= 1
# 一定要记住,文件操作后文件指针也发生了偏移,要用seek归位
# 默认truncate后指针位置不变; 由于原来使用了file.read(),指针当前指向文件末尾;
# 但是文件又是清空了的,再从原来位置写入数据就是访问不存在区域,自然会报错
file.truncate(0)
file.seek(0)
file.write(str(current_ticket_count))
_lock.release()
if __name__ == "__main__":
multiprocessing.set_start_method("spawn")
_lock = multiprocessing.RLock()
for _ in range(os.cpu_count() - 1):
# 进程锁是可以用args共享的
p = multiprocessing.Process(target=task, args=(_lock, ))
p.start()
分析:
①注意观察到task()函数,如果多进程下同时运行该函数,并且不加锁的话,存在这样一种情况:A进程刚读完file中的内容,+1后准备写入,但此时B进程快人一步,已经提前写入新的数据;此时A进程再写入,结果就覆盖了B进程刚写入的数据; 因此必须要加锁!
②注意此时使用的锁是multiprocessing.Rlock, 这是进程锁而非线程锁;
其实仔细想想也可以理解,如果把主进程的主线程锁交给子进程,多少有点慌;因为线程粒度比进程小得多,你将控制精度更小的线程交给一个甚至有进程风险的子线程保管,显然是极其危险的。
四、进程池
特别注意一点,在Python中,multiprocessing模块和concurrent.futures模块都提供了进程池,
但是为了代码风格的统一(毕竟线程池也不是threading模块提供的,而是concurrent.futures模块提供的),我们最好使用concurrent.futures中的进程池。
进程池和线程池的用法几乎没有差异,唯一要注意的一点是进程池里Futures对象的add_done_callback执行的回调函数是在主进程中进行的!
下面通过一个项目简单体会一下进程池,
项目目标,统计各个日志文件中访问量以及访问人数(不重复的ip数)
最终打印出形如这样的字典:
{"20240918": {"total": 120339, "ip": 87322}, ......}
①首先生成虚拟数据, 请先在当前目录下创建一个名为"resourses"的文件夹,随后在文件夹中创建一个Python文件,复制以下代码,生成数据, 注意要自行修改每个要生成的日志条数以及日志文件名,尽量多生成一些日志文件,这样在后面多进程中才能体现出多进程的提速效果。
import random
from datetime import datetime, timedelta
def get_random_ip():
global last_id
# 随机生成IP地址
repeat = random.randint(0, 1)
if not repeat:
last_id = '.'.join(str(random.randint(0, 255)) for _ in range(4))
return last_id
# 随机生成User Agent
user_agents = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/602.4.8 (KHTML, like Gecko) Version/10.0.3 Safari/602.4.8",
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:53.0) Gecko/20100101 Firefox/53.0",
"Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1",
"Mozilla/5.0 (iPad; CPU OS 9_3_2 like Mac OS X) AppleWebKit/601.1 (KHTML, like Gecko) Version/9.0 Mobile/13F69 Safari/601.1"
]
def get_random_user_agent():
return random.choice(user_agents)
# 随机生成访问时间
def get_random_date(start_year=2015, end_year=2024):
start_date = datetime.strptime(f"{start_year}-01-01", "%Y-%m-%d")
end_date = datetime.strptime(f"{end_year}-12-31", "%Y-%m-%d")
delta_days = (end_date - start_date).days
random_days = random.randrange(delta_days)
return (start_date + timedelta(days=random_days)).strftime("%d/%b/%Y:%H:%M:%S")
# 生成日志条目
def generate_log_entry():
ip = get_random_ip()
user_agent = get_random_user_agent()
date = get_random_date()
request = f"GET / HTTP/1.1"
status = random.choice([200, 301, 404, 500])
size = random.randint(100, 1500)
return f"{ip} - - [{date}] \"{request}\" {status} {size} \"{user_agent}\"\n"
def generate_logs(filename, num_entries=1000):
# 生产日志文件
with open(filename, 'w') as file:
for _ in range(num_entries):
log_entry = generate_log_entry()
file.write(log_entry)
if __name__ == "__main__":
last_id = "118.199.64.14"
log_filename = "20240923.log"
generate_logs(log_filename, 136745)
②在当前目录下创建Python文件,执行下属代码:
(这份代码我个人认为已经没有需要解释的地方了,写得非常清晰, 自行体会吧)
"""
语法几乎和线程池没有差异,唯一需要注意的是进程池里的回调函数add_done_callback
不是在子进程中完成的,而是由主进程调度完成
案例: 计算每天用户访问情况
"""
from concurrent import futures
from multiprocessing import Manager
from os import scandir, path, cpu_count
from time import time
def timeit(func):
def wrapper(*args, **kwargs):
start_time = time()
result = func(*args, **kwargs)
end_time = time()
print(f"{func.__name__} took {end_time - start_time} seconds to execute.")
return result
return wrapper
def task(log_file_path, share_dict):
ip_set = set()
log_dict = {"total": 0, "ip": 0}
log_file_name = path.basename(log_file_path)
with open(log_file_path, "r", encoding="utf-8") as f:
for log_data in f:
ip = log_data.split(" - - ", )[0]
ip_set.add(ip)
log_dict["total"] += 1
log_dict["ip"] = len(ip_set)
share_dict[log_file_name] = log_dict
def task2(log_file_path):
ip_set = set()
log_dict = {"total": 0, "ip": 0}
with open(log_file_path, "r", encoding="utf-8") as f:
for log_data in f:
ip = log_data.split(" - - ", )[0]
ip_set.add(ip)
log_dict["total"] += 1
log_dict["ip"] = len(ip_set)
return log_dict
def callback_func(share_dict, file_name):
def inner(future: futures.Future):
share_dict[file_name] = future.result()
return inner
@timeit
def single_process():
share_dict = dict()
for dir_entry in scandir("resources"):
task(dir_entry.path, share_dict)
print(share_dict)
@timeit
def multi_process_no_callback():
manager: Manager = Manager()
share_dict = manager.dict()
# 直接榨干cpu哈哈哈
with futures.ProcessPoolExecutor(max_workers=cpu_count()-1)as pool:
for dir_entry in scandir("resources"):
pool.submit(task, dir_entry.path, share_dict)
print(share_dict)
manager.shutdown() # 手动关闭manager共享资源对象
@timeit
def multi_process_callback():
# 使用回调函数,可以不创建共享资源对象manager, 通过callback函数在主进程中将子进程的结果汇总
share_dict = dict()
with futures.ProcessPoolExecutor(max_workers=cpu_count()-1)as pool:
for dir_entry in scandir("resources"):
future: futures.Future = pool.submit(task2, dir_entry.path)
future.add_done_callback(callback_func(share_dict, dir_entry.name))
print(share_dict)
if __name__ == "__main__":
# single_process() 0.9s
# 0.68s multi_process_no_callback() 主要是进程调度还是很耗时的,因此就这点任务量还不足以体现多进程的优势
# 综合比较,不创建共享资源对象使用回调函数的方式是最高效的! 耗时0.48s
multi_process_callback()