并发编程

目录

并发编程

并发与串行

目前程序存在的问题

程序默认执行方式就是 串行,即程序自上而下,一行一行顺序执行,必须把当前任务执行完毕才能执行下一个任务,无论当前任务需要多少时间

举例:

  • 如TCP服务器中,如果正在进行通讯循环则无法处理其他的客户端请求

  • 如从硬盘中读取大文件

  • 执行了input

学习并发的目的

编写可以同时执行多个任务的程序,来提高效率

串行和并发都是程序处理任务的方式

实现并发的方式

  1. 多进程
  2. 多线程
  3. 协程

操作系统是什么

操作系统是一款特殊的软件

操作系统的主要功能

  1. 隐藏了硬件系统复杂的操作,提供了简单直观的API接口

  2. 将硬件的竞争变得有序可控

  3. 提供了GUI,图形化用户界面

与普通软件上的区别:

  1. 操作系统可以直接与硬件交互

  2. 操作系统是受保护的,不能直接修改

  3. 操作系统更加长寿,一旦完成基本不会修改,例如系统内核

操作系统发展史

为了掌握多道技术的实现原理,这也是多进程的实现原理

第二代计算机

使用的是批处理系统,存在三个问题:

  1. 需要人为参与

  2. 任务串行执行

  3. 程序员调试效率低

第三代计算机

  1. 使用SPOOLING联机技术

  2. 多道技术(重点)

  3. 多终端多用户

多道技术实现原理:

  1. 空间复用
同一时间,加载多个任务到内存中,多个进程之间内存区域需要相互隔离,这种隔离是物理层面的隔离,其目的是为了保证数据安全

  1. 时间复用
指的是,操作系统会在多个进程之间做切换执行

切换任务的两种情况:

* 当一个进程遇到IO操作时会自动切换

* 当一个任务执行时间超过了阈值会强制切换

注意:在切换前必须保存状态,以便后续恢复执行,并且频繁的切换其实也需要消耗资源,当所有任务都没有IO操作时,切换执行效率反而会下降,但是为了保证并发执行,必须牺牲效率


有了多道技术,计算机就可以同时并发处理多个任务。

第四代计算机

大规模集成电路 + 多用户终端系统

体积降低,成本降低,发张出了个人电脑PC

特点:大多具备GUI界面,即使是普通人不具备专业技能也能流畅使用

并发编程中的重要概念(重要)

  • 串行:程序自上而下顺序执行

  • 并发:多个任务同时执行,但是本质是在不同进程间切换执行,由于速度非常快,所以感觉是同时运行

  • 并行:真正的同时运行,必须具备多核CPU,有几个核心就能同时并行几个任务,当任务数量超过核心数还是并发执行

以上为描述处理任务的三种方式。

  • 阻塞:指的是程序遇到IO操作,无法继续执行代码时的一种状态

  • 非阻塞:指的是程序没有遇到IO操作的一种状态

注意:阻塞和非阻塞也可以用来描述执行任务的方式,input()默认是一个阻塞的操作,但是我们可以用一些手段将阻塞的操作变成非阻塞的操作,例如非阻塞的socket

一个进程的三种状态:

  • 阻塞

  • 运行

  • 就绪

多进程

进程是什么

进程指的是正在运行的程序,是操作系统调度以及进行资源分配的基本单位

当把一个程序从硬盘读入到内存时,进程就产生了

多进程:指的是同一时间有多个程序装入内存并执行

进程来自于操作系统,由操作系统进行调度以及资源分配,多进程的实现原理其实就是操作系统调度进程的原理

进程的创建和销毁

对于通用计算机而言.必须具备创建和销毁进程的能力

创建:

1.用户的交互式请求:鼠标双击

2.由一个正在运行的程序调用了开启进程的接口:例如subprocess

3.一个批处理作业开始

4.系统初始化

销毁:

1.任务完成:自愿退出

2.强制结束:taskkill、kill、非自愿

3.程序遇到了异常

4.严重错误:比如访问了不该访问的内存

进程和程序

程序是一堆代码放在一个文件中,通常后缀为exe,原本是存储在硬盘上的

进程是将代码从硬盘读取到内存然后执行产生的

进程是由程序产生的

一个程序可以产生多个进程,例如qq多开,每一个进程都具备一个PID,进程变编号且是唯一的

进程的层次结构

在linux中,进程具备父子关系,是一个树状结构,可以互相查找到对方

在windows 没有层级关系,父进程可以将子进程的句柄转让

例如qq开启了浏览器 那么qq是父进程 浏览器是子进程

PID 和 PPID

PID是当前进程的编号

PPID是父进程的编号

注意:当我们运行py文件时其实运行的是Python解释器

# 访问PID和PPID
import os

os.getpid()
os.getppid()

python如何使用多进程

创建子进程的方式:

1. 导入multiprocessing中的Process类,实例化这个类,指定要执行的任务target

import os
from multiprocessing import Process


def task():
    print('this is sub process')
    print(f'sub process is {os.getpid()}')


if __name__ == '__main__':
    # ######注意 开启进程的代码必须放在 ————main————判断下面
    #  实例化一个进程对象 并制定他要做的事情  用函数来指定

    p = Process(target=task)
    p.start()   # 给操作系统发送消息 让它开启进程

    print('this is parent process')
    print(f'parent process is {os.getpid()}')
    print('over')

linux 与windows开启进程的方式不同

linux 会将父进程的内存数据 完整copy一份给子进程

注意:

  • windows 会导入父进程的代码 从头执行一遍 来获取需要处理的任务

  • 所以在编写代码时如果是windows一定要将开启进程的代码放main判断中

  • linux 可以不放

2.导入multiprocessing中的Process类,继承这个类,覆盖run方法,将要执行的任务放入run中开启进程时会自动执行该函数

如果需要对进程对象进行高度自定义那就可以继承它

from multiprocessing import Process
import os


class MyProcess(Process):

    def run(self):
        print(os.getpid())


if __name__ == '__main__':

    m = MyProcess()
    m.start()
    print(f'parent over :{os.getpid()}')

进程之间内存相互隔离

from multiprocessing import Process
import os
import time

a = 257


def task():
    global a
    # print("2",a,id(a))
    a = 200


if __name__ == '__main__':
    p = Process(target=task)
    p.start()  # 向操作系统发送指令

    time.sleep(4)
    print(a)
257

join函数

from multiprocessing import Process
import time


def task1(name):
    for i in range(10000):
        print("%s run" % name)


def task2(name):
    for i in range(100):
        print("%s run" % name)


if __name__ == '__main__':  # args 是给子进程传递的参数 必须是元组
    p1 = Process(target=task1, args=("p1",))
    p1.start()  # 向操作系统发送指令
    # p1.join()   # 让主进程 等待子进程执行完毕在继续执行

    p2 = Process(target=task2, args=("p2",))
    p2.start()  # 向操作系统发送指令

    p2.join()  # 让主进程 等待子进程执行完毕在继续执行
    p1.join()

    # 需要达到的效果是 必须保证两个子进程是并发执行的 并且 over一定是在所有任务执行完毕后执行
    print("over")
# join的使用
from multiprocessing import Process
import time


def task1(name):
    for i in range(10):
        print("%s run" % name)


if __name__ == '__main__':  # args 是给子进程传递的参数 必须是元组

    ps = []
    for i in range(10):
        p = Process(target=task1, args=(i,))
        p.start()
        ps.append(p)

    # 挨个join以下
    for i in ps:
        i.join()

    print("over")

进程对象的常用属性

if __name__ == '__main__':
    p = Process(target=task, name="老司机进程")
    p.start()
    # p.join()
    # print(p.name)
    # p.daemon #守护进程
    # p.join()
    # print(p.exitcode) # 获取进程的退出码   就是exit()函数中传入的值
    # print(p.is_alive())  # 查看进程是否存活
    # print("zi",p.pid) # 获取进程id
    # print(os.getpid())
    # p.terminate()  #终止进程与strat相同的是 不会立即终止,因为操作系统有很多事情要做
    # print(p.is_alive())

孤儿进程和僵尸进程

孤儿进程:当父进程已经结束而子进程还在运行子进程就称为孤儿进程,有其存在的必要性,没有不良影响

僵尸进程:当一个进程已经结束了但是,它仍然还有一些数据存在,此时称之为僵尸进程

在linux中,有这么一个机制,父进程无论什么时候都可以获取到子进程的的一些数据

子进程任务执行完毕后,确实结束了但是仍然保留一些数据 目的是为了让父进程能够获取这些信息

linux中 可以调用waitpid来是彻底清除子进程的残留信息

python中 已经封装了处理僵尸进程的操作 ,无需关心

守护进程

什么是守护进程

在Python中,守护进程也是一个进程,默认情况下,主进程即使代码执行完毕了,也会等待子进程结束才会结束自己

当一个进程b设置为另一个进程a的守护进程时,a是被守护,b是守护进程

特点:当被守护进程a结束时,即使b的任务没有完成也会随之结束。

from multiprocessing import Process
import time


def task():
    print("zi run")
    time.sleep(3)
    print("zi over")


if __name__ == '__main__':
    p = Process(target=task)
    p.daemon = True  # 将这个进程设置为了守护进程  必须在开启进程前设置

    p.start()

    print("zu over")

zu over

进程安全问题

当并发的多个任务,要同时操作同一个资源,就会造成数据错乱的问题。

解决方法:将并发操作公共资源的代码由并发变为串行,解决安全问题,但是牺牲效率。

串行方式一:

直接用join函数

  • 缺点:将任务中所有代码全都串行,还不如不要开多进程,多个进程之间原本公平竞争,join函数强行规定了执行顺序

串行方式二:

使用互斥锁:互相排斥的锁

原理:将要操作公共资源的代码锁起来,以保证同一时间只能有一个进程在执行这部分代码

优点:可以仅将部分代码串行

注意:必须保证互斥锁只有一把

from multiprocessing import Process, Lock
import time
import random


def task1(mutex):
    # 假设这不是访问公共资源 那么还可也并发执行
    for i in range(10000):
        print(1)

    mutex.acquire()  # 这是加锁
    time.sleep(random.random())
    print("-------name is tom")
    time.sleep(random.random())
    print("-------gender is girl")
    time.sleep(random.random())
    print("-------age is 18")
    mutex.release()  # 解锁


def task2(mutex):
    for i in range(10000):
        print(2)

    mutex.acquire()
    time.sleep(random.random())
    print("++++++++name is jerry")
    time.sleep(random.random())
    print("++++++++gender is oldman")
    time.sleep(random.random())
    print("++++++++age is 48")
    mutex.release()


if __name__ == '__main__':
    mutex = Lock()  # 创建一把互斥锁
    print("创建锁了!!!!")

    p1 = Process(target=task1, args=(mutex,))
    p2 = Process(target=task2, args=(mutex,))

    p1.start()
    p2.start()

加锁:解决了安全问题,带来了效率降低的问题

锁其实只是给执行代码加了限制,本质是一个标志,为True或False

如何使得即保证安全,又提高效率?

锁的粒度

粒度指的是被锁住的代码的多少   

粒度越大锁住的越多 效率越低    

互斥锁案例

抢票

def show():
    with open("db.json") as f:
        data = json.load(f)
        print("剩余票数", data["count"])


def buy():
    with open("db.json") as f:
        data = json.load(f)
        if data["count"] > 0:
            data["count"] -= 1
            with open("db.json", "wt") as f2:
                json.dump(data, f2)
                print("抢票成功!")


def task(mutex):
    show()
    mutex.acquire()
    buy()
    mutex.release()


if __name__ == '__main__':
    mutex = Lock()
    for i in range(5):
        p = Process(target=task, args=(mutex,))
        p.start()

IPC

Inter-Process Communication,进程间通讯

空间复用中内存隔离开了多个进程之间不能直接交互

几种方式

  1. 创建一个共享文件
缺点:效率较低

优点:理论上交换的数据量可以非常大

适用于:交互不频繁,数据量较大的情况
  1. 共享内存
缺点:数据量不能太大

优点:效率高

适用于:交互频繁,数据量小的情况

  1. 管道

    管道也是基于文件,但是单向的,编程比较复杂

  2. socket

编程复杂,更适用于基于网络交换数据


共享内存的方式

Manager:可以为我们创建进程间同步的容器,但是没有处理安全问题,所以不常用

Queue

Queue队列,一种特殊的容器,存取顺序为先进先出

可以完成进程间通讯

from multiprocessing import Queue

q = Queue(2)  # 创建队列 并且同时只能存储2个元素
q.put(1)
q.put(2)

# q.put(3,block=True,timeout=3)
# 默认是阻塞的 当容器中没有位置了就阻塞 直到有人从里面取走元素为止
print(q.get())
print(q.get())
print(q.get(block=True, timeout=3))
# 默认是阻塞的 当容器中没有位置了就阻塞 直到有人存入元素为止

栈也是一种特殊的容器,特殊在于存取顺序为:先进后出

函数调用栈

调用函数时,称之为函数入栈

函数执行结束,称之为函数出栈

生产者消费者模型

模型就是解决某个问题的固定方法或套路

要解决什么问题

生产者:泛指产生数据的一方

消费者:泛指处理数据的一方

案例:

食堂饭店是生产者,客人是消费者

问题:效率低,因为双方的处理速度不同,一个快一个慢,则双方需要相互等待

具体的解决方法:

  1. 先将双方解开耦合,让不同的进程负责不同的任务
  2. 提供一个共享的容器,来平衡双方的能力,之所以用进程队列是因为队列可以在进程间共享。
from multiprocessing import Process, Queue
import requests
import re
import os
import time
import random


# 生产者任务
def product(urls, q):
    i = 0
    for url in urls:
        response = requests.get(url)
        text = response.text

        # 将生产完成的数据放入队列中
        time.sleep(random.random())
        q.put(text)
        i += 1
        print(f'{os.getpid()}生产了第{i}个数据')


def customer(q):
    i = 0
    while True:
        text = q.get()
        time.sleep(random.random())
        res = re.findall('src=//(.*?) width', text)
        i += 1
        print(f'第{i}个任务获取到{len(res)}个img')


if __name__ == '__main__':
    urls = [
        "http://www.baidu.com",
        "http://www.baidu.com",
        "http://www.baidu.com",
        "http://www.baidu.com",
    ]

    # 创建一个双方能共享的容器
    q = Queue()

    # 生产者进程
    p1 = Process(target=product, args=(urls, q))
    p1.start()

    # 消费者进程
    c = Process(target=customer, args=(q,))
    c.start()

问题:消费者不知道什么时候结束

joinableQueue继承了Queue,用法一致,增加了jointaskdone

join是个阻塞函数,会阻塞直到taskdone的调用次数等于存入的元素个数,可以用于表示队列任务处理完成。

from multiprocessing import Process, JoinableQueue
import requests
import re
import os
import time
import random

"""
生产者 负责生产热狗 
消费者 负责吃热狗  


"""

# 生产者任务


def product(q, name):
    for i in range(5):
        dog = "%s的热狗%s" % (name, (i + 1))
        time.sleep(random.random())
        print("生产了", dog)
        q.put(dog)

# 吃热狗


def customer(q):
    while True:
        dog = q.get()
        time.sleep(random.random())
        print("消费了%s" % dog)
        q.task_done()  # 标记这个任务处理完成


if __name__ == '__main__':

    # 创建一个双方能共享的容器
    q = JoinableQueue()

    # 生产者进程
    p1 = Process(target=product, args=(q, "上海分店"))
    p2 = Process(target=product, args=(q, "北京分店"))

    p1.start()
    p2.start()

    # 消费者进程
    c = Process(target=customer, args=(q,))
    # c.daemon = True # 可以将消费者设置为守护进程 当主进程确认 任务全部完成时 可以随着主进程一起结束
    c.start()

    p1.join()
    p2.join()  # 代码走到这里意味着生产方完成

    q.join()  # 意味着队列中的任务都处理完成了

    # 结束所有任务
    c.terminate()  # 直接终止消费者进程

    # 如何判定今天的热狗真的吃完了
    # 1.确定生成者任务完成
    # 2.确定生出来的数据已经全部处理完成

多线程

什么是线程

回顾:进程是操作系统可以调度和进行资源分配的基本单位,是一个资源单位,其中包含了运行这个程序所需要的资源

线程是操作系统可以运算调度的最小单位,是真正的执行单位,其包含在进程中,一个线程就是一条固定的控制流程

一个进程可以包含多个线程,同一进程中的线程共享进程内的资源

特点:系统会为每一个进程自动创建一条线程,称之为主线程,后续通过代码开启的线程称之为子线程。

进程对比线程

进程是一个资源单位,线程是执行单位

创建线程的开销远大于线程

多个进程之间内存是相互隔离的,而线程是共享进程内所有资源

进程之间是竞争关系,而线程是协作关系

开启线程也是需要消耗资源的

进程之间有层级关系,线程之间是平等的

比喻:计算机是工厂,进程就是车间,线程是流水线

为什么用线程

  1. 有多个任务要并发处理

  2. 当要并发处理的任务有很多时,不能使用进程,进程资源开销太大,线程开销非常小,适用于任务数量非常多的情况。

使用线程

方式一:直接实例化Thread

from threading import Thread

# def task():
#     print("子线程 run")
#
# # 与进程不同之处1   不需要加判断 开启线程的代码放哪里都可以
# t = Thread(target=task)
# t.start()
# print("over")


# 使用方法2  继承Thread类
class MyThread(Thread):

    def run(self):
        # 把要在子线中执行的代码放入run中
        print("子 run")


mt = MyThread()
mt.start()
print("over")

线程安全问题

只要并发访问了同一资源一定会产生安全问题,解决方案和多进程一致,就是给操作公共资源的代码加锁。

from threading import Thread, Lock
import time
a = 10

l = Lock()


def task():
    global a
    l.acquire()
    temp = a
    time.sleep(0.1)
    a = temp - 1
    l.release()


ts = []
for i in range(10):
    t = Thread(target=task)
    t.start()
    ts.append(t)
for t in ts:
    t.join()

print(a)

守护线程

一个线程a设置为b的守护进程,a会随着b的结束而结束

默认情况下,主线程即使代码执行完毕也会等待所有非守护线程完毕后程序才能结束,因为多线程之间是协作关系。

from threading import Thread
import time


# 妃子的一生
def task():
    print("妃子 start")
    time.sleep(5)
    print("妃子 over")


def task2():
    print("皇太后 start")
    time.sleep(3)
    print("皇太后 over")


# 皇帝的一生
print("主 start")

t = Thread(target=task)
t.daemon = True
t.start()

t2 = Thread(target=task2)
t2.start()

print("主 over")


"""结果
主 start 
妃子start 
皇太后 start 
主over 
皇太后 over

"""

线程中的常用属性和方法

from threading import Thread, currentThread, enumerate, activeCount
import time

# t = Thread()
# t.start()
# t.join()
# t.is_alive()
# t.isAlive()
# t.ident  # 线程标识符   id
# t.daemon

# 获取当前线程对象
# print(currentThread())
# t = Thread(target=lambda :print(currentThread()))
# t.start()

t = Thread(target=lambda: time.sleep(1))
t.start()

t = Thread(target=lambda: time.sleep(1))
t.start()
t.join()
# 获取正在运行的所有线程对象  是一个列表
print(enumerate())

# 存活的线程数量
print(activeCount())

死锁现象(重要)

死锁指的是某个资源被占用后一直得不到释放,导致其他需要这个资源的线程进入阻塞状态。

产生死锁的情况:

1.对同一把互斥锁,加锁了多次。

2.一个共享资源要访问时必须同时具备多把锁,但是这些锁被不同线程或进程所持有,就会导致相互等待对方释放,从而程序卡死。


第二种死锁的解决方法:

1.抢锁时按照相同的顺序去抢

2.给抢锁加上超时,超时后则放弃执行


递归锁

可以解决第一种死锁现象,同一个线程可以多次acquire,不会卡死,但是必须要在结束后解锁,解锁次数与加锁次数相同,这样其他线程才能够抢到锁。

信号量

可以限制同时并发执行公共代码的线程数量。

如果限制数量为1,则与普通互斥锁就没有区别了。

from threading import Semaphore, currentThread, Thread
import time

s = Semaphore(5)


def task():
    s.acquire()
    time.sleep(1)
    print(currentThread().name)
    s.release()


for i in range(10):
    Thread(target=task).start()

注意:信号量是用来限制最大的并发量,不是来解决安全问题的。

GIL

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple
native threads from executing Python bytecodes at once. This lock is necessary mainly
because CPython’s memory management is not thread-safe. (However, since the GIL
exists, other features have grown to depend on the guarantees that it enforces.)

在cpython中,这个全局解释器锁或者称为GIL是一个互斥锁,是为了阻止这个本地线程在同一时间执行python字节码
因为cpython的内存管理是非线程安全的,这个锁是非常必要的,因为其他越来越多的特性依赖这个特性。

GIL带来的问题

首先必须明确执行一个py文件,分为三个步骤:

  1. 从硬盘加载Python解释器到内存

  2. 从硬盘加载py文件到内存

  3. 解释器解析py文件内容,交给CPU执行

其次需要明确的是每当执行一个py文件,就会立即启动一个Python解释器。

GIL,全局解释器锁,是加到解释器上,也是一把互斥锁。

py文件中的内容本质都是字符串,只有在被解释器解释时,才具备语法意义,解释器会将py代码翻译为当前系统支持的指令交给系统执行。

当进程中仅存在一条线程时,GIL锁的存在没有任何影响,但是如果进程中有多个线程时,GIL锁就开始发挥作用了。

开启子线程时,给子线程指定了一个target表示该子线程要处理的任务即要执行的代码。代码要执行则必须交由解释器,即多个线程之间就需要共享解释器,为了避免共享带来的数据竞争问题,于是就给解释器加上了互斥锁!

由于互斥锁的特性,程序串行,保证数据安全,降低执行效率,GIL将使得程序整体效率降低!

为什么需要GIL

GIL与GC

在使用Python进行编程时,程序员无需参与内存管理工作,这是因为Python自带的内存管理机制,简称GC。那么GC和GIL有什么关联?

首先,需要了解GC的工作原理,Python中内存管理使用的是引用计数,每个数会被加上一个整型的计数器,表示这个数据被引用的次数,当这个整数变为0时则表示该数据已经没人使用,成了垃圾数据。

当内存占用达到某个阈值时,GC会将其他线程挂起,然后执行垃圾清理操作,垃圾清理也是一串代码,也就需要一条线程来执行。

所以这就造成了竞争问题,假设线程1正在定义变量a=10,而定义变量第一步会先到到内存中申请空间把10存进去,第二步将10的内存地址与变量名a进行绑定,如果在执行完第一步后,CPU切换到了GC线程,GC线程发现10的地址引用计数为0则将其当成垃圾进行了清理,等CPU再次切换到线程1时,刚刚保存的数据10已经被清理掉了,导致无法正常定义变量。

当然其他一些涉及到内存的操作同样可能产生问题,为了避免GC与其他线程竞争解释器带来的问题,cpython简单粗暴的给解释器加了互斥锁。

有了GIL后,多个线程不可能在同一时间使用解释器,从而保证了解释器的数据安全。

GIL的加锁与解锁时机

加锁的时机:在调用解释器时立即加锁

解锁时机:

  • 当前线程遇到IO时释放
  • 当前线程执行时间超出设定值时释放

关于GIL的性能讨论

GIL的优点:

  • 保证了cpython中的内存管理是线程安全的

GIL的缺点:

  • 互斥锁的特性使得多线程无法并行

但是我们并不能否认Python这门语言,原因如下:

  1. GIL仅仅在cpython解释器中存在,在其他解释器中没有,并不是Python这门语言的缺点

  2. 在单核处理器下,多线程之间本来就无法并行执行

  3. 在多核处理下,运算效率的确是比单核处理器高,但是要知道现代应用程序多数都是基于网络的(qq,微信,爬虫,浏览器等等),CPU的运行效率是无法决定网络速度的,而网络的速度是远远比不上处理器的运算速度,则意味着每次处理器在执行运算前都需要等待网络IO,这样一来多核优势也就没有那么明显了

举例:

任务1:从网络上下载一个网页,等待网络IO的时间为1分钟,解析网页数据花费,1秒钟

任务2:将用户输入数据并将其转换为大写,等待用户输入时间为1分钟,转换为大写花费,1秒钟

单核CPU下:1.开启第一个任务后进入等待。2.切换到第二个任务也进入了等待。一分钟后解析网页数据花费1秒解析完成切换到第二个任务,转换为大写花费1秒,那么总耗时为:1分+1秒+1秒 = 1分钟2秒

多核CPU下:1.CPU1处理第一个任务等待1分钟,解析花费1秒钟。1.CPU2处理第二个任务等待1分钟,转换大写花费1秒钟。由于两个任务是并行执行的所以总的执行时间为1分钟+1秒钟 = 1分钟1秒

可以发现,多核CPU对于总的执行时间提升只有1秒,但是这边的1秒实际上是夸张了,转换大写操作不可能需要1秒,时间非常短!

上面的两个任务都是需要大量IO时间的,这样的任务称之为IO密集型,与之对应的是计算密集型即IO操作较少大部分都是计算任务。

对于计算密集型任务,Python多线程的确比不上其他语言,为了解决这个弊端,Python推出了多进程技术,可以良好的利用多核处理器来完成计算密集型任务

总结:

  1. 单核下无论是IO密集还是计算密集GIL都不会产生影响

  2. 多核下对于IO密集任务,GIL会有细微的影响,基本可以忽略

  3. cpython中IO密集型任务应该采用多线程,计算密集型应该采用多进程

另外:之所以广泛采用CPython解释器,就是因为大量的应用程序都是IO密集型的,还有另一个很重要的原因是CPython可以无缝对接各种C语言实现的库,这对于一些数学计算相关的应用程序而言非常的happy,直接就能使用各种现成的算法

from multiprocessing import Process
from threading import Thread
import time
# 计算密集型任务


def task():
    for i in range(100000000):
        1+1


if __name__ == '__main__':
    start_time = time.time()

    ps = []
    for i in range(5):
        p = Process(target=task)
        # p = Thread(target=task)
        p.start()
        ps.append(p)

    for i in ps:
        i.join()

    print("共耗时:", time.time()-start_time)

# 多进程胜


# IO密集型任务

def task():
    for i in range(100):
        with open(r"1.死锁现象.py", encoding="utf-8") as f:
            f.read()


if __name__ == '__main__':
    start_time = time.time()

    ps = []
    for i in range(10):
        p = Process(target=task)
        # p = Thread(target=task)
        p.start()
        ps.append(p)

    for i in ps:
        i.join()
    print("共耗时:", time.time()-start_time)

# 多线程胜

自定义的线程锁与GIL的区别

GIL保护的是解释器级别的数据安全,比如对象的引用计数,垃圾分代数据等,具体见垃圾回收机制。

对于程序中自己定义的数据则没有任何的保护效果,这一点在没有介绍GIL前我们就已经知道了,所以当程序中出现了共享自定义的数据就要自己加锁。

from threading import Thread, Lock
import time

a = 0


def task():
    global a
    temp = a
    time.sleep(0.01)
    a = temp + 1


t1 = Thread(target=task)
t2 = Thread(target=task)
t1.start()
t2.start()
t1.join()
t2.join()
print(a)

过程分析

  1. 线程1获得CPU执行权,并获取GIL锁执行代码,得到a的值为0后进入睡眠,释放CPU并释放GIL

  2. 线程2获得CPU执行权,并获取GIL锁执行代码,得到a的值为0后进入睡眠,释放CPU并释放GIL

  3. 线程1睡醒后获得CPU执行权,并获取GIL执行代码,将temp的值0+1后赋给a,执行完毕后释放CPU并释放GIL

  4. 线程2睡醒后获得CPU执行权,并获取GIL执行代码,将temp的值0+1后赋给a,执行完毕后释放CPU并释放GIL,最后a的值也就是1

出现这个问题是因为两个线程在并发的执行同一段代码,解决方案就是加锁!

from threading import Thread, Lock
import time

lock = Lock()
a = 0


def task():
    global a
    lock.acquire()
    temp = a
    time.sleep(0.01)
    a = temp + 1
    lock.release()


t1 = Thread(target=task)
t2 = Thread(target=task)
t1.start()
t2.start()
t1.join()
t2.join()
print(a)

过程分析

  1. 线程1获得CPU执行权,并获取GIL锁,获取lock锁执行代码,得到a的值为0后进入睡眠,释放CPU并释放GIL,不释放lock

  2. 线程2获得CPU执行权,并获取GIL锁,尝试获取lock失败,无法执行,释放CPU并释放GIL

  3. 线程1睡醒后获得CPU执行权,获取GIL锁继续执行代码,将temp的值0+1赋给a,执行完毕后释放CPU,释放GIL,释放lock,此时a的值为1

  4. 线程2获得CPU执行权,获取GIL锁,尝试获取lock成功,执行代码,得到a的值为1后进入睡眠,释放CPU并释放GIL,不释放lock

  5. 线程2睡醒后获得CPU执行权,获取GIL继续执行代码,将temp的值1+1后赋给a,执行完毕释放CPU释放GIL,释放lock,此时a的值为2

进程池与线程池

什么是进程/线程池

池表示一个容器,本质上就是一个存储进程或线程的列表

池子中存储线程还是进程

如果是IO密集型任务使用线程池,如果是计算密集型任务则使用进程池

为什么需要进程/线程池

在很多情况下需要控制进程或线程的数量在一个合理的范围,例如TCP程序中,一个客户端对应一个线程,虽然线程的开销小,但是肯定不能无限开,否则系统资源迟早被耗尽,解决的办法就是控制线程的数量。

线程/进程池不仅帮我们控制线程/进程的数量,还帮我们完成了线程/进程的创建,销毁,以及任务的分配。

# 进程池的使用

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time
import os

# 创建进程池,指定最大进程数为3,此时不会创建进程,不指定数量时,默认为CPU和核数
pool = ProcessPoolExecutors(3)


def task():
    time.sleep(1)
    print(f'{os.getppid()} is working...')


if __name__ == '__main__':
    for i in range(10):
        pool.submit(task)  # 提交任务立即创建进程

    # 任务执行完成后也不会立即销毁进程
    time.sleep(2)

    for i in range(10):
        pool.submit(task)  # 再有新任务是 直接使用之前已经创建好的进程来执行
# 线程池的使用

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
from threading import current_thread, active_count
import time
import os

# 创建进程池,指定最大线程数为3,此时不会创建线程,不指定数量时,默认为CPU和核数*5
pool = ThreadPoolExecutors(3)
print(active_count())  # 只有一个主线程


def task():
    time.sleep(1)
    print(f'{current_thread().name} is working...')


if __name__ == '__main__':
    for i in range(10):
        pool.submit(task)  # 第一次提交任务时立即创建线程

    # 任务执行完成后也不会立即销毁
    time.sleep(2)

    for i in range(10):
        pool.submit(task)  # 再有新任务时 直接使用之前已经创建好的线程来执行

注意:TCP是IO密集型,应该使用线程池

同步异步-阻塞非阻塞

同步异步-阻塞非阻塞,经常会被程序员提及,并且概念非常容易混淆!

阻塞-非阻塞:指的是程序的运行状态

阻塞:当程序执行过程中遇到IO操作,在执行IO操作时,程序无法继续执行其他代码,称为阻塞。

非阻塞:程序在正常运行没有遇到IO操作,或者通过某种方式使程序即时遇到了也不会停在原地,还可以执行其他操作,以提高CPU的占用率。

同步-异步:指的是提交任务的方式

同步:发起任务后必须在原地等待任务执行完成,才能继续执行

异步:发起任务后不用等待任务执行,可以立即开启执行其他操作

注意:同步会有等待的效果,但是这和阻塞是完全不同的,阻塞时程序会被剥夺CPU执行权,而同步调用则不会!

很明显异步调用效率更高,但是任务的执行结果如何获取呢?

# 程序中的异步调用并获取结果方式一:

from concurrent.futures import ThreadPoolExecutor
from threading import current_thread
import time


pool = ThreadPoolExecutor(3)


def task(i):
    time.sleep(0.01)
    print(f'{current_thread().name} is working...')
    return i**i


if __name__ == '__main__':
    objs = []
    for i in range(3):
        res_obj = pool.submit(task, i)  # 异步方式提交任务# 会返回一个对象用于表示任务结果
        objs.append(res_obj)


# 该函数默认是阻塞的,会等待池子中所有的任务执行结束后执行
pool.shutdown(wait=True)

# 从结果对象中取出执行结果
for res_obj in objs:
    print(res_obj.result())

print('over')
# 程序中的异步调用并获取结果方式二

from concurrent.futures import ThreadPoolExecutor
from threading import current_thread
import time


pool = ThreadPoolExecutor(3)


def task(i):
    time.sleep(0.01)
    print(f'{current_thread().name} is working...')
    return i**i


if __name__ == '__main__':
    objs = []

    for i in range(3):
        res_obj = pool.submit(task, i)  # 会返回一个对象用于表示任务结果
        print(res_obj.result())  # result是同步的一旦调用就必须等待 任务执行完成拿到结果

print('over')

异步回调

什么是异步回调

在发起一个异步任务的同时指定一个函数,在异步任务完成时会自动的调用这个函数。

为什么需要异步回调

之前在使用线程池或进程池提交任务时,如果想要处理任务的执行结果则必须调用result函数或是shutdown函数,而他们都是阻塞的,会等到任务执行完毕后才能继续执行,这样一来在这个等待过程中就无法执行其他任务,降低了效率,所以需要一种方案,即保证解析结果的线程不用等待,又能保证数据能够及时被解析,该方案就是异步回调。

异步回调的使用

在编写爬虫程序时,通常都是两个步骤:

  1. 从服务器下载一个网页文件

  2. 读取并且解析文件内容,提取有用的数据

按照以上流程可以编写一个简单的爬虫程序

要请求网页数据则需要使用到第三方的请求库requests可以通过pip或是pycharm来安装,在pycharm中点击settings->解释器->点击+号->搜索requests->安装

import requests
import re
import os
import random
import time
from concurrent.futures import ProcessPoolExecutor


def get_data(url):
    print("%s 正在请求%s" % (os.getpid(), url))
    time.sleep(random.randint(1, 2))
    response = requests.get(url)
    print(os.getpid(), "请求成功 数据长度", len(response.content))
    # parser(response) # 3.直接调用解析方法  哪个进程请求完成就那个进程解析数据  强行使两个操作耦合到一起了
    return response


def parser(obj):
    data = obj.result()
    htm = data.content.decode("utf-8")
    ls = re.findall("href=.*?com", htm)
    print(os.getpid(), "解析成功", len(ls), "个链接")


if __name__ == '__main__':
    pool = ProcessPoolExecutor(3)
    urls = ["https://www.baidu.com",
            "https://www.sina.com",
            "https://www.python.org",
            "https://www.tmall.com",
            "https://www.mysql.com",
            "https://www.apple.com.cn"]
    # objs = []
    for url in urls:
        # res = pool.submit(get_data,url).result() # 1.同步的方式获取结果 将导致所有请求任务不能并发
        # parser(res)

        obj = pool.submit(get_data, url)
        obj.add_done_callback(parser)  # 4.使用异步回调,保证了数据可以被及时处理,并且请求和解析解开了耦合
        # objs.append(obj)

    # pool.shutdown() # 2.等待所有任务执行结束在统一的解析
    # for obj in objs:
    #     res = obj.result()
    #     parser(res)
    # 1.请求任务可以并发 但是结果不能被及时解析 必须等所有请求完成才能解析
    # 2.解析任务变成了串行,
# 线程池中回调


def task(num):
    time.sleep(1)
    print(num)
    return "hello python"


def callback(obj):
    print(obj.result())


pool = ThreadPoolExecutor()
res = pool.submit(task, 123)
res.add_done_callback(callback)
print("over")

线程事件Event

什么是事件

事件表示在某个时间发生了某个事情的通知信号,用于线程间协同工作。

因为不同线程之间是独立运行的状态不可预测,所以一个线程与另一个线程间的数据是不同步的,当一个线程需要利用另一个线程的状态来确定自己的下一步操作时,就必须保持线程间数据的同步,Event就可以实现线程间同步。

Event介绍

Event象包含一个可由线程设置的信号标志,它允许线程等待某些事件的发生。在 初始情况下,Event对象中的信号标志被设置为假。如果有线程等待一个Event对象, 而这个Event对象的标志为假,那么这个线程将会被一直阻塞直至该标志为真。一个线程如果将一个Event对象的信号标志设置为真,它将唤醒所有等待这个Event对象的线程。如果一个线程等待一个已经被设置为真的Event对象,那么它将忽略这个事件, 继续执行

可用方法:

event.isSet():返回event的状态值;

event.wait():将阻塞线程;知道event的状态为True

event.set(): 设置event的状态值为True,所有阻塞池的线程激活进入就绪状态, 等待操作系统调度;

event.clear():恢复event的状态值为False。

from threading import Thread, Event
import time

# is_boot = False
e = Event()


def start_server():
    # global is_boot
    print("starting server......")
    time.sleep(3)
    print("server started!")
    # is_boot = True
    # 修改事件的值为True
    e.set()


def connect_server():
    e.wait()  # 等待事件从False 变为true
    if e.is_set():
        print("连接服务器成功!")
    # while True:
    #     if is_boot:
    #         print("连接服务器成功!")
    #         break
    #     else:
    #         print("失败 服务器未启动!")
    #     time.sleep(0.5)


t1 = Thread(target=start_server)

t2 = Thread(target=connect_server)

t1.start()
t2.start()

线程队列

Queue,LifoQueue,PriorityQueue

# 普通队列 Queue

from queue import Queue

q = Queue(2)

q.put(1)
q.put(2)


print(q.get())
q.task_done()

print(q.get())
q.task_done()

q.join()  # 其原理等同于joinableQueue
print('over')
1
2
over
# last in first out 后进先出,用于模拟栈这种容器

from queue import LifoQueue

l = LifoQueue()
l.put(1)
l.put(2)


print(l.get())
print(l.get())
print('over')
2
1
over
# 具备优先级的队列,取数据时,会比较大小,越小优先级越高

from queue import PriorityQueue

p = PriorityQueue()

p.put((2,3))
p.put((2,5))
p.put((2,1))
p.put((2,))

print(p.get())
print(p.get())
print(p.get())
print(p.get())
(2,)
(2, 1)
(2, 3)
(2, 5)
# 运算符重载,使得对象可以被比较

class Person:
    
    def __init__(self,name,age):
        self.name = name
        self.age = age
        
        
    # 覆盖比较运算符,当在两个对象之间使用比较运算符时,会自动使用该方法
    def __lt__(self,other):
        if self.age == other.age:
            return self.name < other.name
        return self.age < other.age
    

    
p1 = Person('tom',18)
p2 = Person('jerry',17)

p.put(p1)
p.put(p2)

print(p.get().name)
print(p.get().name)
jerry
tom

协程

引子

上一节中我们知道GIL锁将导致CPython中多线程无法并行执行,只能并发的执行。

而并发实现的原理是切换+保存,那就意味着使用多线程实现并发,就需要为每一个任务创建一个线程,必然增加了线程创建销毁与切换的带来的开销

明显的问题就是,高并发情况下,由于任务数量太多导致无法开启新的线程,使得即没有实际任务要执行,也无法创建新线程来处理新任务的情况

如何解决上述问题呢,首先要保证并发效果,然后来想办法避免创建线程带来的开销问题;

协程既是因此而出现的,其原理是使用单线程来实现多任务并发,那么如何能实现单线程并发呢?

单线程实现并发

是否可行

并发:指的是多个任务同时发生,看起来好像是同时都在进行

并行:指的是多个任务真正同时进行

早期的计算机只有一个CPU,既然CPU可以切换线程来实现并发,那么为何不能在线程中切换任务来并发呢?

所以单线程实现并发理论上是可行的

如何实现

并发 = 切换任务+保存状态,只要找到一种方案,能够在两个任务间切换执行并且保存状态,那么就可以实现单线程并发。

Python中的生成器就具备这样一个特点,每次调用next都会回到生成器函数中执行代码,这意味着生成器会自动保存执行状态!

利用生成器来实现并发执行:

def task1():
    for i in range(3):
        yield
        print('task1 run')
        
def task2():
    g = task1()
    for i in range(3):
        next(g)
        print('task2 run')
        
task2()
task2 run
task1 run
task2 run
task1 run
task2 run

并发虽然实现了,但是这对效率的影响是好是坏呢?

# 两个计算任务一个采用生成器切换并发执行,一个直接串行调用

import time

def task1():
    a = 0
    for i in range(10000000):
        a += 1
        yield
        
def task2():
    g = task1()
    b = 0
    for i in range(10000000):
        b += 1
        next(g)
        
        
start1 = time.time()
task2()
print(f'并发执行时间{time.time()-start1}')




# 单线程下串行执行两个计算任务,效率反而会比并发高,因为并发需要切换和保存

def task3():
    a = 0
    for i in range(10000000):
        a += 1
        
def task4():
    b = 0
    for i in range(10000000):
        b += 1
        
start2 = time.time()
task1()
task2()
print(f'串行执行时间{time.time()-start2}')
并发执行时间3.0441150665283203
串行执行时间2.871220350265503

可以看到对于纯计算任务而言,单线程并发反而使执行效率下降了,所以这样的方案对于纯计算而言是没有必要的。

greenlet模块实现并发

我们暂且不考虑这样的并发对程序的好处是什么,在上述代码中,使用yield来切换是的代码结构非常混乱,如果十个任务需要切换呢,不敢想象!因此就有人专门对yield进行了封装,这便有了greenlet模块。

from greenlet import greenlet

def eat(name):
    print(f'{name} eats 1')
    g2.switch('tom')
    print(f'{name} eats 2')
    g2.switch()
    
def play(name):
    print(f'{name} plays 1')
    g1.switch()
    print(f'{name} plays 2')
    
    
g1 = greenlet(eat)
g2 = greenlet(play)

g1.switch('jerry')  #可以在第一次switch时传入参数,以后都不需要再次传
jerry eats 1
tom plays 1
jerry eats 2
tom plays 2

该模块简化了yield复杂的代码结构,实现了单线程下多任务并发,但是无论直接使用yield还是greenlet都不能检测IO操作,遇到IO时同样进入阻塞状态,同样的对于纯计算任务而言效率也是没有任何提升的。

# 切换
from greenlet import greenlet
import time

def f1():
    res = 1
    for i in range(10000000):
        res += 1
        g2.switch()
        
def f2():
    res = 1
    for i in range(10000000):
        res *= i
        g1.switch()
        
start = time.time()
g1 = greenlet(f1)
g2 = greenlet(f2)
g1.switch()

print(f'run time is {time.time()-start}')
run time is 8.30185866355896

greenlet只是提供了一种比generator更加便捷的切换方式,当切到一个任务执行时如果遇到io,那就原地阻塞,仍然是没有解决遇到IO自动切换来提升效率的问题,

任务的代码通常会既有计算操作又有阻塞操作,我们完全可以在执行任务1时遇到阻塞,就利用阻塞的时间去执行任务2。。。。如此,才能提高效率,这就用到了Gevent模块。

协程

协程:是单线程下的并发,又称为微线程,纤程。英文名Coroutine。是一种用户态的轻量级线程,即协程是由用户程序自己控制调度的。

需要强调的是:

  1. Python的线程属于内核级别的,即由操作系统控制调度(如单线程遇到IO或执行时间过长就会被迫交出CPU执行权,切换其他线程进行)

  2. 单线程内开启协程,一旦遇到IO,就会从应用程序级别(而非操作系统)控制切换,以此来提升效率(!!!非IO操作的切换与效率无关)

对比操作系统控制线程的切换,用户在单线程内控制协程的切换

优点如下:

  1. 协程的切换开销更小,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级

  2. 单线程内就可以实现并发效果,最大限度地利用CPU

缺点如下:

  1. 协程的本质是单线程下,无法利用多核,可以是一个程序开启多个进程,每个进程内开启多个线程,每个线程内开启协程来尽可能提高效率

  2. 协程本质是单个线程,因而一旦协程出现阻塞,将会阻塞整个线程

gevent模块

gevent是一个第三方库,可以轻松通过gevent实现并发编程,在gevent中用到的主要模式是greenlet,它是以C扩展模块形式接入Python的轻量级协程。greenlet全部运行在主程序操作系统进程的内部,但是它们被协作式地调度。

常用方法:
# 用法
# 创建一个协程对象g1,
g1 = gevent.spawn(func,1,2,3,x=4,x=5)
# spwan括号内第一个参数是函数名,如eat,后面可以有多个参数,可以是位置实参或关键字实参,都是传给函数eat的
g2 = gevent.spwan(func2)


g1.join()  # 等待g1结束
g2.join()  # 等待g2结束


# 或者上述两步合为一步:gevent.joinall([g1,g2])

g1.value # 拿到func1的返回值
遇到IO阻塞时会自动切换任务
import gevent
from gevent import monkey  # 导入monkey补丁
monkey.patch_all() # 打补丁
import time


print('start...')


def task1():
    print('task1 run')
    time.sleep(2)
    print('task1 over')
    
    
def task2():
    print('task2 run')
    time.sleep(1)
    print('task2 over')
    
    
g1 = gevent.spawn(task1)
g2 = gevent.spawn(task2)

g1.join()
g2.join()

# 执行以上代码会发现不会输出任何消息
# 这是因为协程任务都是以异步方式提交,所以主线程会继续往下执行,而一旦执行完最后一行主线程也就结束了,
# 导致了协程任务没有来的及执行,所以这时候必须join来让主线程等待协程任务执行完毕   也就是让主线程保持存活
# 后续在使用协程时也需要保证主线程一直存活,如果主线程不会结束也就意味着不需要调用join
start...
task1 run
task2 run
task2 over
task1 over
d:python3libsite-packagesgeventhub.py:154: UserWarning: libuv only supports millisecond timer resolution; all times less will be set to 1 ms
  with loop.timer(seconds, ref=ref) as t:
需要注意:
  1. 如果主线程结束了,协程任务也会立即结束。

  2. monkey补丁的原理是把原始的阻塞方法替换为修改后的非阻塞方法,即偷梁换柱,来实现IO自动切换。

必须在打补丁后再使用相应的功能,避免忘记,建议写在最上方

我们可以用threading.current_thread().getName()来查看每个g1和g2,查看的结果为DummyThread-n

monkey补丁原理
# myjson.py
def dump():
    print('一个被替换的dump函数')
    
    
def load():
    print('一个被替换的load函数')
# test.py

import myjson
import json

# 补丁函数
def monkey_patch_json():
    json.dump = myjson.dump
    json.load = myjson.load
    
    
# 打补丁
monkey_patch_json()


# 测试
json.dump()
json.load()

# 输出:
# 一个被替换的 dump函数
# 一个被替换的 load函数
使用gevent案例一 爬虫:
from gevent import monkey
monkey.patch_all()

import gevent
import requests
import time


def get_page(url):
    print(f'GET:{url}')
    response = requests.get(url)
    if response.status_code == 200:
        print(f'{len(response.text)} bytes received from {url}')
        
        
start_time = time.time()
gevent.joinall([
    gevent.spawn(get_page,'https://www.python.org/')
    gevent.spawn(get_page,'https://www.yahoo.com/')
    gevent.spawn(get_page,'https://github.com/')
]
)

stop_time = time.time()
print(f'run time is {stop_time - start_time}')
使用gevent案例二 TCP:

服务器

#=====================================服务端
from gevent import monkey;monkey.patch_all()
from socket import *
import gevent

#如果不想用money.patch_all()打补丁,可以用gevent自带的socket
# from gevent import socket
# s=socket.socket()

def server(server_ip,port):
    s=socket(AF_INET,SOCK_STREAM)
    s.setsockopt(SOL_SOCKET,SO_REUSEADDR,1)
    s.bind((server_ip,port))
    s.listen(5)
    while True:
        conn,addr=s.accept()
        gevent.spawn(talk,conn,addr)

def talk(conn,addr):
    try:
        while True:
            res=conn.recv(1024)
            print('client %s:%s msg: %s' %(addr[0],addr[1],res))
            conn.send(res.upper())
    except Exception as e:
        print(e)
    finally:
        conn.close()

if __name__ == '__main__':
    server('127.0.0.1',8080)

客户端

#=====================================多线程模拟多个客户端并发访问
from threading import Thread
from socket import *
import threading

def client(server_ip,port):
    c=socket(AF_INET,SOCK_STREAM) #套接字对象一定要加到函数内,即局部名称空间内,放在函数外则被所有线程共享,则大家公用一个套接字对象,那么客户端端口永远一样了
    c.connect((server_ip,port))

    count=0
    while True:
        c.send(('%s say hello %s' %(threading.current_thread().getName(),count)).encode('utf-8'))
        msg=c.recv(1024)
        print(msg.decode('utf-8'))
        count+=1
if __name__ == '__main__':
    for i in range(500):
        t=Thread(target=client,args=('127.0.0.1',8080))
        t.start()

IO模型

模型即解决某个问题的固定套路

IO指的是输入输出

IO的问题:当我们要输入数据或是输出数据通常需要很长一段时间,当然是对CPU而言

在等待输入的过程中,CPU就处于闲置状态,造成了资源浪费

注意:IO其实有很多类型,例如socket网络IO,内存到内存的copy,等待键盘输入,对比起来socket网络IO需要等待的时间是最长的,这也是重点要关注的。

学习IO模型要干什么?就是在等待IO操作的过程中利用CPU做别的事。

网络IO经历的步骤和过程

操作系统有两种状态:内核态和用户态,当操作系统需要控制硬件的时候,例如接收网卡上的数据,必须先转换到内核态,接收数据后,要把数据从操作系统的缓冲区copy到应用程序的缓冲区,从内核态转为用户态。

涉及的步骤:

  1. wait_data

  2. copy_data

recv,accept 需要经过wait $ ightarrow$ copy

send 只需要经历copy

阻塞IO模型

默认情况下,写出的TCP程序就是阻塞IO模型

该模型提高效率的方式,当你执行recv/accept会进入wait_data的阶段

  1. 你的程序会主动调用一个block指令,进程进入阻塞状态,同时让出CPU的执行权,操作系统就会将CPU分配给其他的任务,从而提高了CPU的利用率

  2. 当数据到达时,首先会从内核将数据copy到应用程序缓冲区,并且socket将唤醒处于自身的等待队列中的所有进程

之前使用多线程,多进程完成的并发,其实都是阻塞IO模型,每个线程在执行recv时,也会卡住。

非阻塞IO模型

非阻塞IO模型与阻塞模型相反,在调用recv/accept时都不会阻塞当前线程

使用方法:将原本阻塞的socket设置为非阻塞

该模型在没有数据到达时,会跑出异常,我们需要捕获异常,然后继续不断地询问系统内核直到数据到达为止

可以看出,该模型会大量占用CPU资源做一些无效的循环,效率低于阻塞IO

多路复用IO模型

属于事件驱动模型

多个socket使用同一套处理逻辑

如果将非阻塞IO比喻是点餐的话,相当于你每次去前台,照着菜单挨个问个遍

多路复用,直接在菜做好后,前台会返回一个列表,里面就是已经做好的菜

对比阻塞或非阻塞模型,增加了一个select,来帮我们检测socket的状态,从而避免了我们自己检测socket带来的开销

select会把已经就绪的放入列表中,我们需要遍历列表,分别处理读写即可。

# 服务端
import socket
import time
import select

server = socket.socket() 
server.bind(('127.0.0.1',8000))
server.setblocking(True)
server.listen(5)

# 待检测是否可读的列表
r_list = [server]
# 待检测是否可写的列表
w_list = []

# 待发送的数据 {'obj':'data'}
msgs = {}

print('开始检测了')
while True:
    read_ables,write_ables,_ = select.select(r_list,w_list,[])
    print('检测出结果了')
    
    # 处理可读,就是接收数据
    for obj in read_ables:
        
        if obj == server:
            print('有一个客户端连接了')
            client,address = obj.accept()
            r_list.append(client)
            
        else:
            print('客户端发来数据')
            try:
                data = obj.recv(1024)
                if not data: raise ConnectionResetError
                print(f'客户端发来数据{data.decode("utf8")}')
                w_list.append(obj)

                if obj in msgs:
                      msgs[obj].append(data.upper())
                else:
                      msgs[obj] = [data.upper()]
            except ConnectionResetError:
                obj.close()
                r_list.remove(obj)
    
    # 发送数据
    for obj in write_ables:
        msg_list = msgs.get(obj)
        if msg_list:
            for data in msg_list:
                try:
                    obj.send(data)
                except ConnectionResetError:
                    obj.close()
                    w_list.remove(obj)
                    break
            msgs.pop(obj)
                      
        w_list.remove(obj)

多路复用对比非阻塞,多路复用可以极大降低CPU占用率

注意:多路复用并不完美,因为本质上是多个任务之间是串行的,如果某个任务耗时较长导致其他的任务不能立即执行,多路复用最大的优势就是高并发

异步IO模型

非阻塞IO不等于异步IO,因为copy的过程是一个同步任务,会卡住当前线程,而异步IO是发起任务后,就可以继续执行其他任务,当数据已经copy到程序缓冲区,才会给你得线程发送信号或者执行回调

信号驱动IO模型

见得说就是 当某个事情发生后 会给你的线程发送一个信号,你的线程就可以去处理这个任务

不常用,原因是 socket的信号太多,处理起来非常繁琐

epoll模型的探索与实践

select的问题:

  1. 当进程被唤醒,不清楚到底哪个socket有数据,只能遍历一遍

  2. 每一次select的执行,都需要将这个进程再加入到等待队列中

为了防止重复添加等待队列,当某一次操作完成时,也必须从等待队列中删除进程,所以select最大限制被设置为1024,如此看来select连多线程都比不上

于是推出了poll和epoll

poll只是简单对select进行了优化,但是还不够完美,epoll才是最后解决方案

注意:epoll仅能在Linux中使用

epoll相关函数

import select 导入select模块

epoll = select.epoll() 创建一个epoll对象

epoll.register(文件句柄,事件类型) 注册要监控的文件句柄和事件

事件类型:

  select.EPOLLIN 可读事件

  select.EPOLLOUT 可写事件

  select.EPOLLERR 错误事件

  select.EPOLLHUP 客户端断开事件

epoll.unregister(文件句柄) 销毁文件句柄

epoll.poll(timeout) 当文件句柄发生变化,则会以列表的形式主动报告给用户进程,timeout为超时时间,默认为-1,即一直等待直到文件句柄发生变化,如果指定为1那么epoll每1秒汇报一次当前文件句柄的变化情况,如果无变化则返回空

epoll.fileno() 返回epoll的控制文件描述符(Return the epoll control file descriptor)

epoll.modfiy(fineno,event) fineno为文件描述符 event为事件类型 作用是修改文件描述符所对应的事件

epoll.fromfd(fileno) 从1个指定的文件描述符创建1个epoll对象

epoll.close() 关闭epoll对象的控制文件描述符

# 客户端
import socket


client = socket.socket()
server_address = ('127.0.0.1',8000)

client.connect(server_address)


while True:
    
    msg = input('please enter>>>').strip()
    
    if msg == 'q':
        break
        
    if not msg:
        continue
        
    client.send(msg.encode('utf8'))
    
    data = client.recv(1024)
    print(f"接收数据为{data.decode('utf8')}")

client.close()
# 服务器

import socket
import select

server = socket.socket()
server.bind(('127.0.0.1',8000))
server.listen(5)


msgs = {}

fd_socket = {server.fileno(),server}
epoll = select.epoll()

# 注册服务器的 写就绪
epoll.register(server.fileno(),select.EPOLLIN)

while True:
    for fd,event in epoll.poll():
        sock = fd_socket[fd]
        print(fd,event)
        # 返回的是文件描述符 需要获取对应socket
        
        if sock == server:
            client,addr = server.accept()
        
            epoll.register(client.fileno(),select.EPOLLIN)

            fd_socket[client.fileno()] = client
        
        elif event == select.EPOLLIN:
            data = sock.recv(1024)
            if not data:
                
                # 注销事件
                epoll.unregister(fd)
                
                # 关闭socket
                sock.close()
                
                # 删除socket对应关系
                del fd_socket[fd]
                print('someone is out...')
                continue
                
            print(data.decode('utf8'))
            
            # 读完数据 需要把数据发回去所以接下来更改为写就绪=事件
            epoll.modify(fd,select.EPOLLOUT)
            
            #记录数据
            msgs.append((sock,data.upper()))
        elif event == select.EPOLLOUT:
            for item in msgs[:]:
                if item[0] == sock:
                    sock.send(item[1])
                    msgs.remove(item)
            
            # 切换关注事件为写就绪      
            epoll.modify(fd,select.EPOLLIN)

注意:上述代码只能在Linux下运行,因为epoll模型是Linux内核提供的,上层代码无法实现!

原文地址:https://www.cnblogs.com/WilliamKong94/p/11271409.html