天天看點

并發程式設計之協程

一,引子

本節的主題是基于單線程來實作并發,即隻用一個主線程(很明顯可利用的cpu隻有一個)情況下
實作并發,為此我們需要先回顧下并發的本質:切換+儲存狀态

cpu正在運作一個任務,會在兩種情況下切走去執行其他的任務(切換由作業系統強制控制),
一種情況是該任務發生了阻塞,
另外一種情況是該任務計算的時間過長或有一個優先級更高的程式替代了它
      
并發程式設計之協程
ps:在介紹程序理論時,提及程序的三種執行狀态,而線程才是執行機關,
是以也可以将上圖了解為線程的三種狀态
      

  一:其中第二種情況并不能提升效率,隻是為了讓cpu能夠雨露均沾,實作看起來所有任務都被“同時”執行的效果,如果多個任務都是純計算的,這種切換反而會降低效率。為此我們可以基于yield來驗證。yield本身就是一種在單線程下可以儲存任務運作狀态的方法,我們來簡單複習一下:

1 yiled可以儲存狀态,yield的狀态儲存與作業系統的儲存線程狀态很像,但是yield是
代碼級别控制的,更輕量級
    2 send可以把一個函數的結果傳給另外一個函數,以此實作單線程内程式之間的切換
      

  單純地切換反而會降低運作效率

#串行執行
import time
def consumer(res):
    '''任務1:接收資料,處理資料'''
    pass

def producer():
    '''任務2:生産資料'''
    res=[]
    for i in range(10000000):
        res.append(i)
    return res

start=time.time()
#串行執行
res=producer()
consumer(res) #寫成consumer(producer())會降低執行效率
stop=time.time()
print(stop-start) #1.5536692142486572



#基于yield并發執行
import time
def consumer():
    '''任務1:接收資料,處理資料'''
    while True:
        x=yield

def producer():
    '''任務2:生産資料'''
    g=consumer()
    next(g)
    for i in range(10000000):
        g.send(i)

start=time.time()
#基于yield儲存狀态,實作兩個任務直接來回切換,即并發的效果
#PS:如果每個任務中都加上列印,那麼明顯地看到兩個任務的列印是你一次我一次,即并發執行的.
producer()

stop=time.time()
print(stop-start) #2.0272178649902344
      

  

  二:第一種情況的切換。在任務一遇到io情況下,切到任務二去執行,這樣就可以利用任務一阻塞的時間完成任務二的計算,效率的提升就在于此。

yield并不能實作遇到io切換

import time
def consumer():
    '''任務1:接收資料,處理資料'''
    while True:
        x=yield

def producer():
    '''任務2:生産資料'''
    g=consumer()
    next(g)
    for i in range(10000000):
        g.send(i)
        time.sleep(2)

start=time.time()
producer() #并發執行,但是任務producer遇到io就會阻塞住,并不會切到該線程内的其他任務去執行

stop=time.time()
print(stop-start)
      

  對于單線程下,我們不可避免程式中出現io操作,但如果我們能在自己的程式中(即使用者程式級别,而非作業系統級别)控制單線程下的多個任務能在一個任務遇到io阻塞時就切換到另外一個任務去計算,這樣就保證了該線程能夠最大限度地處于就緒态,即随時都可以被cpu執行的狀态,相當于我們在使用者程式級别将自己的io操作最大限度地隐藏起來,進而可以迷惑作業系統,讓其看到:該線程好像是一直在計算,io比較少,進而更多的将cpu的執行權限配置設定給我們的線程。

  協程的本質就是在單線程下,由使用者自己控制一個任務遇到io阻塞了就切換另外一個任務去執行,以此來提升效率。為了實作它,我們需要找尋一種可以同時滿足以下條件的解決方案:

1. 可以控制多個任務之間的切換,切換之前将任務的狀态儲存下來,以便重新運作時,
可以基于暫停的位置繼續執行。
    2. 作為1的補充:可以檢測io操作,在遇到io操作的情況下才發生切換
      

二,協程介紹

  協程:是單線程下的并發,又稱微線程,纖程。英文名Coroutine。一句話說明什麼是協程:協程是一種使用者态的輕量級線程,即協程是由使用者程式自己控制排程的。、

需要強調的是:

1. python的線程屬于核心級别的,即由作業系統控制排程(如單線程遇到io或執行時間過長
就會被迫交出cpu執行權限,切換其他線程運作)
    2. 單線程内開啟協程,一旦遇到io,就會從應用程式級别(而非作業系統)控制切換,以此
來提升效率(!!!非io操作的切換與效率無關)
      

對比作業系統控制線程的切換,使用者在單線程内控制協程的切換

優點如下:

1. 協程的切換開銷更小,屬于程式級别的切換,作業系統完全感覺不到,因而更加輕量級
2. 單線程内就可以實作并發的效果,最大限度地利用cpu
      

缺點如下:

1. 協程的本質是單線程下,無法利用多核,可以是一個程式開啟多個程序,
每個程序内開啟多個線程,每個線程内開啟協程
2. 協程指的是單個線程,因而一旦協程出現阻塞,将會阻塞整個線程
      

總結協程特點:

  1. 必須在隻有一個單線程裡實作并發
  2. 修改共享資料不需加鎖
  3. 使用者程式裡自己儲存多個控制流的上下文棧
  4. 附加:一個協程遇到IO操作自動切換到其它協程(如何實作檢測IO,yield、greenlet都無法實作,就用到了gevent子產品(select機制))

 三,greenlet子產品

  如果我們在單個線程内有20個任務,要想實作在多個任務之間切換,使用yield生成器的方式過于麻煩(需要先得到初始化一次的生成器,然後再調用send。。。非常麻煩),而使用greenlet子產品可以非常簡單地實作這20個任務直接的切換

#安裝:pip3 install greenlet
from greenlet import greenlet

def eat(name):
    print('%s eat 1' %name)
    g2.switch('egon')
    print('%s eat 2' %name)
    g2.switch()
def play(name):
    print('%s play 1' %name)
    g1.switch()
    print('%s play 2' %name)

g1=greenlet(eat)
g2=greenlet(play)

g1.switch('egon')#可以在第一次switch時傳入參數,以後都不需要
      

  單純的切換(在沒有io的情況下或者沒有重複開辟記憶體空間的操作),反而會降低程式的執行速度

#順序執行
import time
def f1():
    res=1
    for i in range(100000000):
        res+=i

def f2():
    res=1
    for i in range(100000000):
        res*=i

start=time.time()
f1()
f2()
stop=time.time()
print('run time is %s' %(stop-start)) #10.985628366470337

#切換
from greenlet import greenlet
import time
def f1():
    res=1
    for i in range(100000000):
        res+=i
        g2.switch()

def f2():
    res=1
    for i in range(100000000):
        res*=i
        g1.switch()

start=time.time()
g1=greenlet(f1)
g2=greenlet(f2)
g1.switch()
stop=time.time()
print('run time is %s' %(stop-start)) # 52.763017892837524
      

  greenlet隻是提供了一種比generator更加便捷的切換方式,當切到一個任務執行時如果遇到io,那就原地阻塞,仍然是沒有解決遇到IO自動切換來提升效率的問題。

  單線程裡的這20個任務的代碼通常會既有計算操作又有阻塞操作,我們完全可以在執行任務1時遇到阻塞,就利用阻塞的時間去執行任務2。。。。如此,才能提高效率,這就用到了Gevent子產品。

四,gevent子產品

安裝    pip3 install gevent
      

  Gevent 是一個第三方庫,可以輕松通過gevent實作并發同步或異步程式設計,在gevent中用到的主要模式是Greenlet, 它是以C擴充子產品形式接入Python的輕量級協程。 Greenlet全部運作在主程式作業系統程序的内部,但它們被協作式地排程。

#用法
g1=gevent.spawn(func,1,,2,3,x=4,y=5)建立一個協程對象g1,
spawn括号内第一個參數是函數名,如eat,後面可以有多個參數,
可以是位置實參或關鍵字實參,都是傳給函數eat的

g2=gevent.spawn(func2)

g1.join() #等待g1結束

g2.join() #等待g2結束

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

g1.value#拿到func1的傳回值
      

  遇到IO阻塞時會自動切換任務

import gevent
def eat(name):
    print('%s eat 1' %name)
    gevent.sleep(2)
    print('%s eat 2' %name)

def play(name):
    print('%s play 1' %name)
    gevent.sleep(1)
    print('%s play 2' %name)


g1=gevent.spawn(eat,'egon')
g2=gevent.spawn(play,name='egon')
g1.join()
g2.join()
#或者gevent.joinall([g1,g2])
print('主')
      

上例gevent.sleep(2)模拟的是gevent可以識别的io阻塞,

而time.sleep(2)或其他的阻塞,gevent是不能直接識别的需要用下面一行代碼,打更新檔,就可以識别了

from gevent import monkey;monkey.patch_all()必須放到被打更新檔者的前面,如time,socket子產品之前

或者我們幹脆記憶成:要用gevent,需要将from gevent import monkey;monkey.patch_all()放到檔案的開頭

from gevent import monkey;monkey.patch_all()

import gevent
import time
def eat():
    print('eat food 1')
    time.sleep(2)
    print('eat food 2')

def play():
    print('play 1')
    time.sleep(1)
    print('play 2')

g1=gevent.spawn(eat)
g2=gevent.spawn(play_phone)
gevent.joinall([g1,g2])
print('主')
      

  我們可以用threading.current_thread().getName()來檢視每個g1和g2,檢視的結果為DummyThread-n,即假線程

五,Gevent之同步與異步

from gevent import spawn,joinall,monkey;monkey.patch_all()

import time
def task(pid):
    """
    Some non-deterministic task
    """
    time.sleep(0.5)
    print('Task %s done' % pid)


def synchronous():
    for i in range(10):
        task(i)

def asynchronous():
    g_l=[spawn(task,i) for i in range(10)]
    joinall(g_l)

if __name__ == '__main__':
    print('Synchronous:')
    synchronous()

    print('Asynchronous:')
    asynchronous()
#上面程式的重要部分是将task函數封裝到Greenlet内部線程的gevent.spawn。 
初始化的greenlet清單存放在數組threads中,此數組被傳給gevent.joinall 函數,
後者阻塞目前流程,并執行所有給定的greenlet。執行流程隻會在 所有greenlet執
行完後才會繼續向下走。
      

六,Gevent之應用舉例一(協程應用:爬蟲)

from gevent import monkey;monkey.patch_all()
import gevent
import requests
import time

def get_page(url):
    print('GET: %s' %url)
    response=requests.get(url)
    if response.status_code == 200:
        print('%d bytes received from %s' %(len(response.text),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('run time is %s' %(stop_time-start_time))
      

練習

通過gevent實作單線程下的socket并發(from gevent import monkey;monkey.patch_all()一定要放到導入socket子產品之前,否則gevent無法識别socket的阻塞)

服務端

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()
      

不經一番徹骨寒 怎得梅花撲鼻香

繼續閱讀