併發程式設計(四)

HammerZe發表於2022-01-24

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的多執行緒無法使用多核優勢);

GIL肯定存在於CPython直譯器中 主要原因就在於Cpython直譯器的記憶體管理不是執行緒安全的;

記憶體管理:垃圾回收機制

  • 引用計數
  • 標記清除
  • 分代回收

image

Cpython直譯器自帶的GIL直譯器鎖,執行緒要想執行程式碼去搶鎖,搶python直譯器,之後才回收,那麼這樣就能保證了阻止同一個程式下的多個執行緒同時被執行,不容易造成資料錯亂;比如,搶票,如果你提交了訂單,那麼別人還能操作到你這張票的訂單嗎?不會了吧;這樣就進而使資料不容易錯亂;

  • GIL是Cpython直譯器的特點,其實就是一把互斥鎖,犧牲了效率保證了資料的安全(就適用場景而言);
  • python同一個程式內的多個執行緒無法利用多核優勢(不能並行但是可以併發(切換加儲存狀態));
  • 執行緒是執行單位,但是不能直接執行,同一個程式內的多個執行緒要想執行必須先搶GIL鎖,然後拿到python直譯器才能被cpu執行;
  • GIL的存在其實也解決了垃圾回收機制導致資料錯亂的因素,比如你剛執行了name='hz',還沒有來得急繫結關係,垃圾回收機制就可能給你回收了,因為垃圾回收也是執行緒,想要執行也得拿直譯器來執行,但是不是和你的程式碼序列;
  • 所有的解釋型語言幾乎都無法實現同一個程式下的多個執行緒同時被執行,但是可以併發;
  • 多個程式下的執行緒才能實現並行;

驗證GIL存在

驗證之前需要明白什麼是多道技術(切換+儲存狀態)

多道技術什麼時候切換:程式執行時間長、程式有IO操作(√,示例利用IO)

from threading import Thread
import time
m = 100
def test():
    global m
    tmp = m
    # time.sleep(1)  
    # IO操作,造成資料錯亂,sleep(1)執行釋放了GIL鎖,100個執行緒同樣的操作反覆執行,導致結果為99,如果沒有IO操作結果為0
    tmp -= 1
    m = tmp
for i in range(100):
    t = Thread(target=test)
    t.start()
time.sleep(3)
print(m) # 0

'''
同一個程式下的多個執行緒雖然有GIL的存在不會出現並行的效果,但是如果執行緒內有IO操作還是會造成資料的錯亂,這個時候需要我們額外的新增互斥鎖(就不止GIL一把鎖了)
'''

補:搶鎖釋放鎖簡寫方式

a = Lock()
# 方式一:
a.acquire()
'''程式碼體'''
a.release()
# 方式二:
with a:
    '''程式碼體'''
# 適用with上下文管理器,會自動搶鎖釋放鎖

GIL與普通互斥鎖的區別

from threading import Thread,Lock
import time


mutex = Lock()
m = 100
def test():
    global m
    with mutex:
        tmp = m
        time.sleep(0.1)
        # IO操作,造成資料錯亂,sleep(1)執行釋放了GIL鎖,100個執行緒同樣的操作反覆執行,導致結果為99,如果沒有IO操作結果為0
        tmp -= 1
        m = tmp
if __name__ == '__main__':
    t_list = []
    for i in range(100):
        t = Thread(target=test)
        t.start()
        t_list.append(t)
    for t in t_list:
        t.join()
print(m) # 0

死鎖現象(瞭解)

存在多把鎖的情況,會出現死鎖現象;

from threading import Thread, Lock
import time

A = Lock()
B = Lock()
class MyThread(Thread):
    def run(self):
        self.func1()
        self.func2()

    def func1(self):
        A.acquire()  # 搶鎖
        print('%s 搶到了A鎖' % self.name)  # 相當於獲取執行緒名稱
        # current_thread().name  獲取執行緒名稱
        B.acquire()
        print('%s 搶到了B鎖' % self.name)
        time.sleep(1)
        B.release()  # 釋放鎖
        print('%s 釋放了B鎖' % self.name)
        A.release()
        print('%s 釋放了A鎖' % self.name)

    def func2(self):
        B.acquire()
        print('%s 搶到了B鎖' % self.name)
        A.acquire()
        print('%s 搶到了A鎖' % self.name)
        A.release()
        print('%s 釋放了A鎖' % self.name)
        B.release()
        print('%s 釋放了B鎖' % self.name)

for i in range(10):
    obj = MyThread()
    obj.start()

過程

1、執行緒1搶A鎖,B鎖,其他執行緒等待;

2、執行緒1釋放了B鎖,其他執行緒等待,因為A鎖沒有釋放;執行緒1釋放了A鎖,其他執行緒才能去func1中搶鎖;

3、執行緒1去func2中搶B鎖,A鎖,其他執行緒搶func1中的A鎖和B鎖,現在鎖還線上程1手中,那麼就可能導致卡死現象;

4、通俗理解,這樣就導致了,你要的在我手上,我要的在你手上,比如A和B,A在B家被鎖了,B在A家被鎖了,A和B拿的自己家的鑰匙,但是他們在對方的家中,那麼就死鎖了;

image


遞迴鎖(瞭解)

遞迴鎖特點:可以被連續的acquire和release,但是隻能被第一個搶到這把鎖執行上述操作,它的內部有一個計數器,每acquire一次計數加一,每release一次計數減一,只要計數不為0,其他人都無法搶鎖;

模組:RLock

Factory function that returns a new reentrant lock.

    A reentrant lock must be released by the thread that acquired it. Once a
    thread has acquired a reentrant lock, the same thread may acquire it again
    without blocking; the thread must release it once for each time it has
    acquired it.
from threading import Thread,Lock,RLock
import time


'''
mutexA = Lock()
mutexB = Lock()
相當於開啟了兩把鎖;
'''

# 兩把鎖指向同一個記憶體空間地址,開啟一把鎖
A = B = RLock()


class MyThread(Thread):
    def run(self):
        self.func1()
        self.func2()

    def func1(self):
        A.acquire()  # 搶鎖
        print('%s 搶到了A鎖' % self.name)  # 相當於獲取執行緒名稱
        # current_thread().name  獲取執行緒名稱
        B.acquire()
        print('%s 搶到了B鎖' % self.name)
        time.sleep(1)
        B.release()  # 釋放鎖
        print('%s 釋放了B鎖' % self.name)
        A.release()
        print('%s 釋放了A鎖' % self.name)

    def func2(self):
        B.acquire()
        print('%s 搶到了B鎖' % self.name)
        A.acquire()
        print('%s 搶到了A鎖' % self.name)
        A.release()
        print('%s 釋放了A鎖' % self.name)
        B.release()
        print('%s 釋放了B鎖' % self.name)

if __name__ == '__main__':
    for i in range(10):
        obj = MyThread()
        obj.start()

#  遞迴鎖的適用不會死鎖,搶一次鎖計數加1,釋放計數減1,其實就是一把鎖沒有導致混亂

訊號量(瞭解)

訊號量在不同的階段可能對應不同的技術點;

在併發程式設計中訊號量指的也是鎖;

通俗理解:

互斥鎖是一個廁所,那麼訊號量就是多個廁所

from threading import  Thread,Semaphore
import time
import random

sm = Semaphore(5) # 括號內寫幾就代表幾個坑位

def task(name):
    sm.acquire() # 搶鎖
    print(f'{name} is running!')
    # time.sleep(3)
    time.sleep(random.randint(1,5))
    sm.release() # 釋放鎖

if __name__ == '__main__':
    for i in range(20):
        t = Thread(target=task,args=(f'拉屎{i}號',))
        t.start()

# 訊號量可以理解為拉屎的坑位,三個人搶鎖(進入廁所),拉完了就是釋放鎖了;

python多執行緒是否沒用(重點)

如果面試官問你python多執行緒是不是沒用啊?你是不是得分情況回答他,不能直接說有用啊,存在即合理···?

視情況而定來判斷是否需要多執行緒,看程式的型別;

  • 程式的型別
    • IO密集型(常用):需要使用者給定指令或者反饋來執行,比如部落格頁面,你動網頁它就動,不需要實時更進更新等;
    • 計算密集型:需要長時間的計算,比如自動駕駛,是不是需要長時間計算路況,或者匹配路線等;
  • 單核多執行緒牛逼,多核情況下,計算密集型開多程式;IO密集型開多執行緒;
  • 可以開多程式結合多執行緒,哈哈哈要啥有啥?

驗證案例

IO密集型

比如有四個任務,每個任務耗時10s;

  • 開設多程式沒有太大的優勢,共10s+
    • 因為遇到IO操作cpu就不再服務,就需要切換,並且開設程式還需要申請記憶體空間和拷貝程式碼;
  • 開設多執行緒有優勢,10s+
    • 不需要消耗額外的資源,只需要一個CPU(單核情況下IO密集型開多執行緒是完全有優勢的,以耗時任務最長的為準!)
from multiprocessing import Process
from threading import Thread
import threading
import os,time
def work():
    time.sleep(2)


if __name__ == '__main__':
    l=[]
    print(os.cpu_count()) #本機為8核
    start=time.time()
    for i in range(400):
        # 開程式
        # p=Process(target=work) # run time is 10.905012845993042大部分時間耗費在建立程式上
        # 開執行緒
        p=Thread(target=work) # run time is 2.061677932739258
        l.append(p)
        p.start()
    for p in l:
        p.join()
    stop=time.time()
    print('run time is %s' %(stop-start))
    
# IO密集型的時候,同樣的任務多執行緒只需要2秒

計算密集型

計算密集型(佔著cpu不放),比如有四個任務,每個任務耗時10s;

  • 開設多程式可以利用多核優勢,一共10s+
  • 開設多執行緒無法利用多核優勢,因為每個程式都需要10秒,一共40s+
from multiprocessing import Process
from threading import Thread
import os,time
def work():
    res=0
    for i in range(100000000):
        res*=i
if __name__ == '__main__':
    l=[]
    print(os.cpu_count())  # 本機為8核
    start=time.time()
    for i in range(6):
        p=Process(target=work) # 多程式耗時:run time is 6.857659339904785
        # p=Thread(target=work) # 多執行緒耗時:run time is 24.021770000457764
        l.append(p)
        p.start()
    for p in l:
        p.join()
    stop=time.time()
    print('run time is %s' %(stop-start))
    
# 計算密集型,多程式只需6s,這樣只需看一個cpu計算任務所需的時間,那麼多個cpu同時結束;
# 多執行緒就得排隊來了,執行完一個繼續下一個····

Event事件(瞭解)

一些程式/執行緒需要等待另外一些程式/執行緒執行完畢之後才能執行;

通俗理解:比如汽車生成車間,肯定得先衝壓零部件,然後焊接這些部件(不能扯一體式衝壓昂?),然後塗裝工藝,最後總裝···

那麼把這些一個個流程比作執行緒或程式,是不是得前面的進行完才能往下走?

案例:

from threading import Thread,Event
import time

event = Event() # 造紅綠燈

def light():
    print('紅燈停')
    time.sleep(3)
    print('綠燈行')
    # 行人走
    event.set()

def people(name):
    print(f'{name} 等紅綠燈')
    event.wait()
    print(f'{name} 可以走了')

if __name__ == '__main__':
    t = Thread(target=light)
    t.start()

    for i in range(20):
        t = Thread(target=people,args=(f'{i}',))
        t.start()

執行緒q(瞭解)

同一個程式下的多個執行緒資料是共享的,為什麼同一個程式下還會去使用佇列呢?因為佇列是管道和鎖構成的,使用佇列也是為了保證資料得到安全(適用場景自定義)

import queue

# 先進先出的佇列
# q = queue.Queue(3)
# q.put(1)
# q.get()
# q.get_nowait()
# q.full()
# q.empty()

# 後進先出佇列,其實和堆疊大差不差
# q = queue.LifoQueue(3)
# q.put(1)
# q.put(2)
# q.put(3)
# print(q.get()) # 3

# 優先順序佇列:可以給放入佇列中的資料設定進出的優先順序
q = queue.PriorityQueue(4)
q.put((10,'111'))
q.put((100,'222'))
q.put((0,'333'))
q.put((-5,'444'))
print(q.get()) # (-5, '444')

# 數字越小,優先順序越高,put((優先順序,資料))

程式池與執行緒池(掌握)

TCP服務端併發

# 麵條版主體
import socket

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

while True:
    sock,addr = server.accept()
    # 通訊迴圈
    while True:
        try:
            data = sock.recv(1024)
            if len(data) == 0:break
            sock.send(data.upper()) # 傳送大寫
        except ConnectionResetError as e:
            print(e)
            break
    sock.close()
    

封裝版

from threading import Thread
import socket




# 通訊函式
def communication(sock):
    # 通訊迴圈
    while True:
        try:
            data = sock.recv(1024)
            if len(data) == 0: break
            sock.send(data.upper())  # 傳送大寫
        except ConnectionResetError as e:
            print(e)
            break
    sock.close()

# 連線客戶端函式
def server(ip,port):
    server = socket.socket()
    server.bind((ip,port))
    server.listen(5)
    while True:
        sock,addr = server.accept()

        # 開設多程式/多執行緒
        t = Thread(target=communication,args=(sock,))
        t.start()

if __name__ == '__main__':
    s = Thread(target=server,args=('127.0.0.1',8080))
    s.start()

思考:能否無限制的開設程式或者執行緒???

肯定是不能無限制開設的,如果單從技術層面上來說無限開設肯定是可以的並且是最高效,但是從硬體層面上來說是無法實現的(硬體的發展永遠趕不上軟體的發展速度)

這時候就出現了池,我們在合理適用計算機的時候,保證硬體正常工作的前提,去開設多程式和多執行緒,是最合理的,如果硬體崩潰了,軟體也沒用了;

  • 池:池是用來保證電腦保安情況下,最大限度的利用計算機,它的出現降低了程式的執行效率但是保證了計算機硬體的安全,從而相對高效執行;(對比半連線池只限制了等待的數量;)

  • 程式池:提前開設了固定個數的程式 之後反覆呼叫這些程式完成工作(後續不再開設新的)

  • 執行緒池:提前開設了固定個數的執行緒 之後反覆呼叫這些執行緒完成工作(後續不再開設新的)

程式池與執行緒池的基本使用

模組:from concurrent.futures import ThreadPoolExecutor,ProcessPoolExecutor

執行緒池

from concurrent.futures import ThreadPoolExecutor,ProcessPoolExecutor
import time

# 執行緒池:固定開設5個執行緒,5個執行緒不會重複出現重複建立和銷燬(節省資源);
pool = ThreadPoolExecutor(5) # 括號內可以傳數字,不傳預設開設當前計算機cpu個數五倍的執行緒
"""Initializes a new ThreadPoolExecutor instance.

        Args:
            max_workers: The maximum number of threads that can be used to
                execute the given calls.
            thread_name_prefix: An optional name prefix to give our threads.
        """


def task(n):
    print(n)
    time.sleep(2)
    return f'返回結果:{n*2}'

# pool.submit(task,1) # 朝池子中提交任務(非同步)
'''
提交方式:同步、非同步
'''
# print('main')


# 朝池子提交20個任務,每次只能接5個
t_list = []
for i in range(20):
    res = pool.submit(task,i)
    # print(res.result())
    # 返回結果,非同步變序列,同步提交
    t_list.append(res)
pool.shutdown()  # 關閉執行緒池,等待執行緒池中所有任務執行完畢
# 解決了等待卡頓
for t in t_list: # 非同步提交結果,先起任務再返回結果
    print('>>>>',t.result())

程式池

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

# 程式池:固定開設幾個程式,程式不會重複出現重複建立和銷燬(節省資源);
pool = ProcessPoolExecutor(5) # 括號內可以傳數字,不傳預設開設當前計算機cpu個數

def task(n):
    print(n,os.getpid())
    time.sleep(2)
    return f'程式號:{os.getpid()}'

# 非同步提交任務的返回結果,應該通過回撥機制來獲取,而不是下面的for迴圈最後獲取
def call_back(n): # n 返回的物件
    print(n.result())   # n.result 相當於res.result
if __name__ == '__main__':

    # 朝池子提交20個任務,每次只能接5個
    # t_list = []
    for i in range(20):
        res = pool.submit(task,i).add_done_callback(call_back)   # 回撥機制
        # print(res.result())
        # 返回結果,非同步變序列,同步提交
        # t_list.append(res)
    # pool.shutdown()
    # # 解決了等待卡頓
    # for t in t_list: # 非同步提交結果,先起任務再返回結果
    #     print('>>>>',t.result())


主要操作:

from concurrent.futures import ThreadPoolExecutor,ProcessPoolExecutor
pool = ProcessPoolExecutor(5)
res = pool.submit(task,i).add_done_callback(call_back)   # 回撥機制
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time
import os

# 建立程式池與執行緒池
# pool = ThreadPoolExecutor(5)  # 可以自定義執行緒數 也可以採用預設策略
pool = ProcessPoolExecutor(5)  # 可以自定義執行緒數 也可以採用預設策略


# 定義一個任務
def task(n):
    print(n, os.getpid())
    time.sleep(2)
    return '>>>:%s' % n ** 2


# 定義一個回撥函式:非同步提交完之後有結果自動呼叫該函式
def call_back(a):
    print('非同步回撥函式:%s' % a.result())


# 朝執行緒池中提交任務
# obj_list = []
for i in range(20):
    res = pool.submit(task, i).add_done_callback(call_back)  # 非同步提交
    # obj_list.append(res)
"""
同步:提交完任務之後原地等待任務的返回結果 期間不做任何事
非同步:提交完任務之後不願地等待任務的返回結果 結果由非同步回撥機制自動反饋
"""
# 等待執行緒池中所有的任務執行完畢之後 再獲取各自任務的結果
# pool.shutdown()
# for i in obj_list:
#     print(i.result())  # 獲取任務的執行結果  同步

在windows電腦中如果是程式池的使用也需要在__main__下面

協程

名詞解釋

  • 程式:資源單位

  • 執行緒:工作單位

  • 協程:程式設計師自定義的名詞,意思是單執行緒下實現併發(程式設計師自己在程式碼層面上監測我們所有的IO操作,一但遇到IO,我們在程式碼級別完成切換,這樣給cpu的感覺是程式一直在執行,沒有IO操作從而提升效率)

  • 多道技術:切換+儲存技術

  • CPU被剝奪的條件:

    • 程式長時間佔用
    • 程式進入IO操作
  • 併發去實現切換+儲存狀態

欺騙CPU的行為:

單執行緒下我們如果能夠自己檢測IO操作並且自己實現程式碼層面的切換
那麼對於CPU而言我們這個程式就沒有IO操作,CPU會盡可能的被佔用

注意切換不一定能提升效率,如果是IO密集型就會提升效率,計算密集型切換就會降低效率;

gevent模組

能夠自主監測IO行為並切換

from gevent import monkey;monkey.patch_all()
# 固定程式碼格式加上之後才能檢測所有的IO行為
from gevent import spawn
import time


def play(name):
    print('%s play 1' % name)
    time.sleep(5)
    print('%s play 2' % name)
'''
兩個方法有IO操作,程式碼一直在反覆“跳”
'''

def eat(name):
    print('%s eat 1' % name)
    time.sleep(3)
    print('%s eat 2' % name)


start = time.time()
# play('Hammer')  # 正常的同步呼叫
# eat('Hammer')  # 正常的同步呼叫 8s+
g1 = spawn(play, 'Hammer')  # 非同步提交
g2 = spawn(eat, 'Hammer')  # 非同步提交
g1.join()
g2.join()  # 等待被監測的任務執行完畢
print('主', time.time() - start)  # 單執行緒下實現併發,提升效率5s+

協程實現TCP服務端併發的效果

# 併發效果:一個服務端可以同時服務多個客戶端
import socket
from gevent import monkey;monkey.patch_all()
from gevent import spawn
def talk(sock):
    while True:
        try:
            data = sock.recv(1024)
            if len(data) == 0:break
            print(data)
            sock.send(data+b'hi')
        except ConnectionResetError as e:
            print(e)
            sock.close()
            break
def servers():
    server = socket.socket()
    server.bind(('127.0.0.1',8080))
    server.listen()
    while True:
        sock, addr = server.accept()
        spawn(talk,sock)
g1 = spawn(servers)
g1.join()

# 客戶端開設幾百個執行緒發訊息即可
最牛的情況:多程式下開設多執行緒,多執行緒下開設協程
我們以後可能自己動手寫的不多,一般都是使用別人封裝好的模組或框架

IO模型

IO模型研究的主要是網路IO(linux系統),理論為主,程式碼實現大部分為虛擬碼;

基本關鍵字

  • 同步(synchronous)
  • 非同步(asynchronous)
  • 阻塞(blocking)
  • 非阻塞(non-blocking)

研究方向

  • Stevens在文章中一共比較了五種IO Model

  • blocking IO 阻塞IO

  • nonblocking IO 非阻塞IO

  • IO multiplexing IO多路複用

  • signal driven IO 訊號驅動IO

  • asynchronous IO 非同步IO

由signal driven IO(訊號驅動IO)在實際中並不常用,所以主要介紹其餘四種IO Model

四種IO模型介紹

阻塞IO

最為常見的一種IO模型,有兩個等待的階段(wait for data、copy data)

計算機1和計算機2資料傳輸,需要經過拷貝到記憶體到OSI,然後才到計算機2的OSI七層到記憶體;

image


非阻塞IO

系統呼叫階段變為了非阻塞(輪詢) 有一個等待的階段(copy data),輪詢的階段是比較消耗資源的;

通俗的理解:會一直詢問kernel有沒有資料~,並不會阻塞操作,直到copy才會阻塞;

image


多路複用IO

利用select或者epoll來監管多個程式 一旦某個程式需要的資料存在於記憶體中了 那麼立刻通知該程式去取即可;

image

當使用者程式呼叫了select,那麼整個程式會被block,而同時,kernel會“監視”所有select負責的socket,當任何一個socket中的資料準備好了,select就會返回。這個時候使用者程式再呼叫read操作,將資料從kernel拷貝到使用者程式;通俗理解為:多個人排隊取餐,監控select,如果參號了,kernel說好了,然後使用者去取餐return;

這個圖和blocking IO的圖其實並沒有太大的不同,事實上還更差一些。因為這裡需要使用兩個系統呼叫(select和recvfrom),而blocking IO只呼叫了一個系統呼叫(recvfrom)。但是,用select的優勢在於它可以同時處理多個connection;


非同步IO

只需要發起一次系統呼叫 之後無需頻繁傳送 有結果並準備好之後會通過非同步回撥機制反饋給呼叫者;

image


相關文章