Python之并发编程

目录


并发与并行:并发是同一时间段内处理多件事情(交替执行),并行是某一时刻处理多个事情

关于进程

进程是正在运行的程序,是系统进行资源分配的最小单位

进程的内存空间

详解参考链接:进程的内存分布_进程内存_循梦的博客-CSDN博客

进程的内存空间被寄存器界定,不同进程空间之间不能互相访问,两个进程想要通信,必须通过一个中间代理来实现

在32位操作系统中,进程最大内存一般为4G虚拟内存(所谓虚拟内存即物理内存的逻辑映射,这样保证了每个进程都有相同的内存布局,减少了物理内存区段不同的干扰)

其中内核空间1G、用户空间3G

虚拟内存中,内核区段 对于用户应用程序而言是禁闭的 ,它们用于存放操作系统的关键代码,另外由于 Linux 系统的历史性原因,在虚拟内存的最底端 0x00000000 ~ 0x08048000 之间也有一段禁闭的区段,该区段也是不可访问的

用户空间中包含几个部分:

  • 栈区:全称运行时栈,或堆栈。特点是后进先出,系统一般对栈空间大小限制为8MB,一般存储环境变量、命令行变量、局部变量以及函数切换时当下的代码地址和相关寄存器的值 (又叫保存现场)。还需要注意的是:栈内存的分配和释放,都是由系统规定的,我们无法干预。

    shell 复制代码
    [root@zh-ali ~]# cat  /proc/1/limits
    Limit                     Soft Limit           Hard Limit           Units     
    Max cpu time              unlimited            unlimited            seconds   
    Max file size             unlimited            unlimited            bytes     
    Max data size             unlimited            unlimited            bytes     
    Max stack size            8388608              unlimited            bytes     # stack 栈空间
    Max core file size        0                    unlimited            bytes     
    Max resident set          unlimited            unlimited            bytes     
    Max processes             6854                 6854                 processes 
    Max open files            1048576              1048576              files     
    Max locked memory         65536                65536                bytes     
    Max address space         unlimited            unlimited            bytes     
    Max file locks            unlimited            unlimited            locks     
    Max pending signals       6854                 6854                 signals   
    Max msgqueue size         819200               819200               bytes     
    Max nice priority         0                    0                    
    Max realtime priority     0                    0                    
    Max realtime timeout      unlimited            unlimited            us        
    [root@zh-ali ~]# echo $((8388608/1024))
    8192
    [root@zh-ali ~]# echo $((8192/1024))   # 8MB
    8
  • 堆区(heap):又称动态内存、自由内存。是唯一可被开发者自定义的区段,开发者可以根据需要申请内存的大小、决定使用的时间长短等。系统不对此做任何干预,给予开发者绝对的"自由",但也正因如此,对开发者的内存管理提出了很高的要求。对堆内存的合理使用,几乎是软件开发中的一个永恒的话题。

    • 堆内存基本特征:

      • 相比栈内存,堆的总大小仅受限于物理内存,在物理内存允许的范围内,系统对堆内存的申请不做限制。
      • 相比栈内存,堆内存从下往上增长。
      • 堆内存是匿名的,只能由指针来访问。
      • 自定义分配的堆内存,除非开发者主动释放,否则永不释放,直到程序退出。

      相关API:

      • 申请堆内存:malloc() / calloc() / realloc()
      • 清零堆内存:bzero()
      • 释放堆内存:free()
  • 数据段:数据段的大小在进程一开始运行就是固定的。地址从高到低,将数据段分为.bss段、.data段、.rodata段三部分。

    • .bss段专门用来存放未初始化的静态数据(static修饰的局部变量、static修饰的全局变量以及全局变量),它们会在程序刚运行时被系统初始化为0;在程序文件中,它们是没有值的。
    • .data段专门存放已经初始化的静态数据,这个初始值从程序文件中获取
    • .rodata段用来存放只读数据,即常量;比如进程中所有的字符串、字符常量、整型数据、浮点型数据等。
  • 代码段:地址从高到低,将代码段分为.text段以及.init段两部分。

    • .text段也叫正文段,用来存放用户程序代码(所有用户自定义函数)。

    • .init段用来存储系统给每一个可执行程序自动添加的初始化代码,这部分代码功能包括:环境变量的准备、命令行参数的组织和传递等,并且这部分数据被存放在栈底。

    • 注意:数据段和代码段内存的分配和释放,都是由系统规定的,我们无法干预。

进程通信

方法如下:

  • 管道 :一个内核缓冲区,先进先出
    • 无名管道:父子进程之间,有亲缘关系的进程之间进行通信;因为管道没有名字不能确定位置,但子进程被创建时会拷贝父进程的资源,自然就知道了管道的位置
    • 命名管道:无需确定亲缘关系也能通信
  • 消息队列
    • 内核中的消息列表,可以根据自己的情况读取特定的消息
  • 信号
    • ctrl+c、ctrl+z、kill等等都是发送信号
  • 信号量 :是一个计数器,用于实现进程间的互斥和通信,而不是用于存储进程间通信数据
    • P\V操作
    • 若要在进程间传递数据,需结合共享内存使用
  • 共享内存 :高效的进程通信方式
    • 允许两个以上的进程通过映射共享存储区
    • 两种实现方式:内存映射、共享内存机制
    • ipcs -m:查看共享内存段
    • ipcrm:清理共享内存
  • socket套接字
    • 可以用在不同主机之间的通信

线程

线程是操作系统进行调度的最小单位,是一串指令的集合

• 线程被称为轻量级进程(Lightweight Process,LWP),是cpu调度的基本单位

组成 :线程ID、当前指令指针 (PC)、寄存器集合、堆栈组成

线程与进程的区别:

真正运行在cpu上的是线程,线程共享内存空间------------进程的内存是独立的

一个线程只能属于一个进程------------一个进程可以有多个线程,但至少得有一个线程

线程共享进程资源------------进程资源独立

同一个进程的线程可直接通信------------进程间通信需要中间代理

一个线程可以控制和操作同一个进程内的其他线程------------进程操作子进程

一个主线程改变可能会影响其它线程

协程

协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。

  • 协程,又称微线程,纤程,英文名Coroutine。

  • 协程的作用:

    • 在执行函数A时,可以随时中断,去执行函数B,然后中断继续执行函数A(可以自由切换)。

    • 但这一过程并不是函数调用(没有调用语句)

  • 协程避免了无意义的调度,由此可以提高性能,但也因此,程序员必须自己承担调度的责任,同时,协程也失去了标准线程使用多CPU的能力,即无法利用多核资源

进程状态模型

进程的组成

进程的组成:程序控制块PCB、数据段、正文段

pcb

pcb就是一个struct(结构体),包含了许多属性,如:pid、属主、属组、优先级、状态、地址、打开的文件等等

threading模块

创建和启动

python 复制代码
import threading
import time

def sleep(t):
    time.sleep(t)
    print(f"睡眠{t}s")

def runtime(func):
    def inner(t):
        start = time.time()
        func(t)
        end = time.time()
        print(f"共耗时{end-start}s")
    return inner

@runtime
def main(t):
    for i in range(5):
        # sleep(t)
        thread = threading.Thread(target=sleep,args=(t,))   # target传入callable对象,args传入参数(需为元组形式)
        thread.start()  # 启动时自动调用run()方法,run()方法再去调用传递进来的target

main(1)
print("ok")

join阻塞

python 复制代码
@runtime
def main(t):
    thread_list = []
    for i in range(5):
        thread = threading.Thread(target=sleep,args=(t,))
        thread.start()
        thread_list.append(thread)
        # thread.join() # 如果放这里相当与创建一个线程就阻塞一次,并没有并发的效果

    # 所以要等所有线程全都创建启动后再join
    for t in thread_list:
        t.join()    # 线程对象内置方法,将该线程加入到上下文,阻塞当前环境上下文,直到该线程执行完成
        # 其实只有第一个join会阻塞,因为当第一个线程结束时,其它线程也差不多一起结束了

main(1)
print("ok")

设置前台/后台线程

python 复制代码
@runtime
def main(t):
    for i in range(5):
        thread = threading.Thread(target=sleep,args=(t,))
        # 前台线程、后台线程 需在start之前设置
        # 默认是False 即 前台进程 -- 主线程等待子线程结束后才退出
        # 设置为True则为 后台进程 -- 主线程执行结束,子进程就退出
        thread.setDaemon(True)
        thread.start()

自定义线程类

更为常用的实现写法,重点在于重写run()方法

python 复制代码
class Mythread(threading.Thread):
    def __init__(self,num):
        super().__init__()	# 继承父类初始化函数
        self.num = num

    def run(self):
        print(f"hello, num is {self.num}")

t1 = Mythread(998)
t1.start()

线程锁

线程,因为在同一个进程中共享资源,很容易发生资源争抢,产生脏数据,因此需要锁的概念

互斥锁(普通锁)

允许某一部分资源,同时只有一个线程访问

对于类似下面的情形:

python 复制代码
import threading
import time

n = 0
def add_num(i):
    global n
    time.sleep(1)
    n += i
    print(f"num is {n}")

for i in range(10):
    t = threading.Thread(target=add_num,args=(i,))
    t.start()

# --------输出结果----------
num is 6num is 14num is 18num is 23num is 24
num is 27num is 36
num is 38


num is 45


num is 45

输出结果混乱不齐,只因为对公共资源的请求是不可控的,因此需要给公共资源加锁:

python 复制代码
import threading
import time

n = 0
def add_num(i):
    lock.acquire()	# 加锁,返回bool类型
    global n
    time.sleep(1)
    n += i
    print(f"num is {n}")
    lock.release()	# 释放锁

lock = threading.Lock()		# 生成锁实例
for i in range(10):
    t = threading.Thread(target=add_num,args=(i,))
    t.start()

# ----或者换种写法:
def add_num(i):
    with lock:
	    global n
    	time.sleep(1)
    	n += i
	    print(f"num is {n}")
Lock Rlock 原始锁重入锁

是互斥锁的两种类型

python 复制代码
lock1 = threading.Lock()	# 原始锁
lock2 = threading.RLock()	# 重入锁

lock1.acquire()		# 申请锁1
lock1.acquire()		# 再次申请锁1,但lock1为原始锁,在申请锁时不会判断自己是不是已经获取了这把锁,因此陷入无限等待,即死锁

lock2.acquire()
lock2.acquire()		# lock2为重入锁,申请锁时会进行判断,如果有了该锁,则立即返回,不会等待
如何避免产生死锁?
  • 尽量避免同一个线程对多个lock进行锁定

  • 多个线程对多个lock进行锁定时,尽量以相同的顺序加锁(这样需求就不会交叉)

  • 设置超时时间:lock.acquire(timeout=1.2)

信号量Semaphore

最多允许同时N个线程执行内容

相当于有多把钥匙,使用起来和lock类似

事件锁Event

根据状态位,决定是否通过事件

主线程通过决定"Flag"的值来控制其它线程中wait()方法阻塞的事件能否执行

通过发送event信号,其它的线程则等待这个信号(Event是线程间通信最常见的机制之一)

条件锁Condition

该机制使得线程等待,只有满足某条件时,才释放n个线程(可以控制释放个数)

类似于信号量和事件锁的结合

全局解释器锁 GIL

​ --Global Interpreter Lock

该锁与python语言没有关系,只是因为历史遗留问题:官方推荐的解释器cpython中的设定(Jpython无此类问题)

每个线程在执行过程中都需要先获取GIL,保证同一时刻同一进程内只有一个线程可以执行代码

GIL(全局解释器锁)是一种在CPython解释器中使用的机制,它限制了同一时刻只能有一个线程执行Python字节码的能力。这意味着在多线程的情况下,同一时刻只有一个线程能够执行Python代码,其他线程会被阻塞。

GIL的存在是因为CPython的内存管理并不是线程安全的。GIL的作用是保护Python解释器内部的数据结构免受并发访问的影响。然而,这也意味着在多线程的情况下,Python的多线程程序并不能充分利用多核处理器的优势。

由于GIL的存在,CPU密集型 的Python程序在多线程下并不能获得性能的提升。但对于I/O密集型 的程序,多线程依然可以提供一定的性能优势,因为在I/O操作时,线程会释放GIL,让其他线程有机会执行。

• GIL最基本的行为只有下面两个:

  • 当前执行的线程持有GIL

  • 当线程遇到io阻塞或者cpu时间片到时,会释放GIL

因此,在python中,io密集型任务适合使用:多线程、多进程(io密集型任务特点:很长时间都在等待)

计算密集型任务适合使用:多进程

多进程

创建进程:用户创建出来的所有进程都是由操作系统负责,新进程的创建都是由一个已经存在的进程执行了一个用于创建进程的系统调用而创建的

如何创建子进程?

在python中,每一个运行的程序都有一个主进程,可以利用模块中封装的方法来创建子进程------------》os.fork()

os.fork

os.fork中就用来创建子进程的方法

注意:这个os.fork()方法只有在unix系统中才会有,在window下没有。

• 使用fork创建子进程后,操作系统会将当前的进程复制一份

• 原来的进程称为父进程,新创建的进程称为子进程

• 两个进程会各自互不干扰的执行下面的程序-->两个进程都会执行

• 父进程与子进程的执行顺序与系统调度有关

在子进程内,这个方法会返回0;在父进程内,这个方法会返回子进程的编号PID

​ • 返回值为大于0时,此进程为父进程,且返回的数字为子进程的PID;

​ • 当返回值为0时,此进程为子进程。

​ • 如果返回值为负数则表明创建子进程失败。

• 父进程结束时,子进程并不会随父进程立刻结束 。同样,父进程不会等待子进程执行完(当然,可以用join方法阻塞父进程)。

  • 僵尸进程:子进程退出,父进程没有响应,没有调用wait或者waitpid去获取子进程的状态,那么这个子进程的进程描述符就会依然存在系统中,这种进程即僵尸进程
  • 孤儿进程:父进程退出,而子进程继续时,称为孤儿进程,这时子进程认pid为1的进程(systemd)为父进程

os.getpid():获取进程的进程号。

os.getppid():获取父进程的进程号

python 复制代码
[root@sc-server bingfa-test]# cat os-test.py
import os, time

print("start....fork")
result = os.fork()
print("outerside pid is:", result)
if result == 0:		# 子进程返回值为0
	print("child process")
	time.sleep(60)
	print("child pid is:", os.getpid())
	print("child-parent pid is:", os.getppid())
else:				# 父进程返回值为子进程的pid
	print("parent process")
	time.sleep(60)
	print("parent pid is:", os.getpid())

multiprocessing模块

由于windows没有fork调用,python提供了multiprocessing支持跨平台版本。

创建管理进程模块:

• Process(用于创建进程模块)

• Pool(用于创建管理进程池)

• Queue(用于进程通信,资源共享)

• Value,Array(用于进程通信,资源共享)

• Pipe(用于管道通信)

• Manager(用于资源共享)

Process 类

构造方法:Process([group [, target [, name [, args [, kwargs]]]]])

• group: 线程组,目前还没有实现,库引用中提示必须是None;

• target: 要执行的方法;

• name: 进程名;

• args/kwargs: 要传入方法的参数。

示例:

python 复制代码
from multiprocessing import Process, current_process
import time

lst = []

def task(i):
    print(current_process().name, f"start......{i}")
    time.sleep(2)
    lst.append(i)
    print(f"lst = {lst}")		# 注意:这里每个进程都只会输出一个数,因为进程之间的数据互相独立,互相隔离。
    print(current_process().name, f"end......{i}")

if __name__ == "__main__":      # 使用多进程时最好加上这行,不然会报错
    for i in range(4):
        p = Process(target=task,args=(i,))
        p.start()
自定义进程类
python 复制代码
# 自定义进程类
class Myprocess(Process):
    def __init__(self,name):
        super().__init__()
        self.name = name

    def run(self) -> None:
        print(f"running......{self.name}")

if __name__ == "__main__":
    p1 = Myprocess("p1")
    p2 = Myprocess("p2")
    p1.start();p2.start()
进程间通信方法
  • Queue
  • Value,Array
  • Pipe
  • Manager
Manager
python 复制代码
import time
from multiprocessing import Process, Manager


def task(i, temp, lock):
	print("start........")
	time.sleep(2)
	lock.acquire()		# 或者用with lock:
	temp[0] += 100
	print(i,"---->", temp[0])
	lock.release()

if __name__ == "__main__":
	m1 = Manager()		# 实例化Manager对象
	temp = m1.list([1,2,3])		# 创建一个共享列表对象
	lock = m1.Lock()		# 创建一个共享互斥锁,这里其实用Lock()直接生成锁实例也是可以的,锁自带共享属性吧可能
	p_list = []
	for i in range(10):
		p = Process(target=task,args=(i, temp, lock))
		p.start()
		p_list.append(p)
	[p.join() for p in p_list]		# 等待子进程运行完成再往下运行,否则父进程结束后,Manager进程也会受影响
	#父进程先退出的话,manager共享就没有用了
	print("end......")

Manager会启动一个进程,并且通过socket实现

Queue

特点是先进先出,是一种进程安全的数据结构,其进出操作都具有原子性

python 复制代码
from multiprocessing import Process, Queue
import time

def task(i, p):
    if not p.empty():   # 队列是否为空
        time.sleep(1)
        print(i, "---->get value", p.get())     # 取数据,先进先出,所以没有脏数据

if __name__ == "__main__":
    p = Queue()         # 创建队列
    for i in range(10):
        p.put(i)        # 放数据
        q = Process(target=task, args=(i, p))
        q.start()
进程锁

multiprocessing中有threading中的同名锁,包括Lock、RLock、Semaphore、Event、Condition

用法基本相同

进程池

一般我们是通过动态创建子进程(或子线程)来实现并发服务器的,但是会存在这样一些缺点:

  1. 动态创建进程(或线程)比较耗费时间,这将导致较慢的服务器响应。

  2. 动态创建的子进程通常只用来为一个客户服务,这样导致了系统上产生大量的细微进程(或线程)。进程和线程间的切换将消耗大量CPU时间。

  3. 动态创建的子进程是当前进程的完整映像,当前进程必须谨慎的管理其分配的文件描述符和堆内存等系统资源,否则子进程可能复制这些资源,从而使系统的可用资源急剧下降,进而影响服务器的性能。

Pool 进程池

• 进程池的作用:有效的降低频繁创建销毁线程所带来的额外开销。

通常预创建的进程数与cpu核数相等

nginx就是很明显的使用进程池的例子,worker进程就是无论有没有请求都保持监听,当手动杀死其中一个worker进程时,master进程又会创建一个新的worker进程

进程池的原理

• 进程池都是采用预创建的技术,在应用启动之初便预先创建一定数目的进程。

• 应用在运行的过程中,需要时可以从这些进程所组成的进程池里申请分配 一个空闲的进程,来执行一定的任务,任务完成后,并不是将进程销毁,而是将它返还给进程池,由线程池自行管理。

• 如果进程池中预先分配的线程已经全部分配完毕,但此时又有新的任务请求,则进程池会动态的创建新的进程去适应这个请求。

• 某些时段应用并不需要执行很多的任务,导致了进程池中的线程大多处于空闲的状态,为了节省系统资源,进程池就需要动态的销毁其中的一部分空闲进程

• 进程需要一个管理者,按照一定的要求去动态的维护其中进程的数目。

python 复制代码
from multiprocessing import current_process, Pool
import time

def task(i):
	print(current_process().name,f"start.....{i}")
	time.sleep(2)
	print(current_process().name,f'end......{i}')

if __name__ == "__main__":
	p = Pool(processes=4,maxtasksperchild=2)
	for i in range(20):
		p.apply_async(func=task,args=(i,))
	p.close()
	p.join()
	print("end......process")

协程 asyncio模块

协程编程多用于网络高并发的编程

python 复制代码
import asyncio
import time

async def say_after(delay,what):
    print(f"test start......{what}")
    await asyncio.sleep(delay)
    print(what)

async def main():
    task1 = asyncio.create_task(
        say_after(4,'hello'))
    task2 = asyncio.create_task(
        say_after(5,'world'))

    print(f"started at {time.time()}")

asyncio.run(main())

小结

本节文章讲解了python中的并发编程,从操作系统层面的进程和内存入手,随后讲解了多线程、多进程、进程池以及多协程的实现,其中对线程和进程讲解较为详细,对于python协程的使用笔者还没有深入学习,毕竟并发编程也不是python的优势所在,对于并发编程,go语言或许才是目前的首选,笔者后续也可能会学习更新相关的知识,感谢大家的观看,有什么问题也欢迎留言讨论,谢谢。

相关推荐
山川而川-R2 分钟前
ubuntu22.04安装PaddleX3
python·ocr
海威的技术博客22 分钟前
JS中的原型与原型链
开发语言·javascript·原型模式
WPG大大通30 分钟前
基于DIODES AP43781+PI3USB31531+PI3DPX1207C的USB-C PD& Video 之全功能显示器连接端口方案
c语言·开发语言·计算机外设·开发板·电源·大大通
从以前44 分钟前
【算法题解】Bindian 山丘信号问题(E. Bindian Signaling)
开发语言·python·算法
海绵波波1071 小时前
flask后端开发(9):ORM模型外键+迁移ORM模型
后端·python·flask
余生H1 小时前
前端Python应用指南(二)深入Flask:理解Flask的应用结构与模块化设计
前端·后端·python·flask·全栈
high20111 小时前
【Java 基础】-- ArrayList 和 Linkedlist
java·开发语言
1nullptr1 小时前
lua和C API库一些记录
开发语言·lua