Berbicara tentang async/await
dan asyncio
bukanlah hal yang sama. Yang pertama adalah konstruksi dasar tingkat rendah (coroutine) sedangkan yang berikutnya adalah pustaka yang menggunakan konstruksi ini. Sebaliknya, tidak ada jawaban akhir tunggal.
Berikut ini adalah gambaran umum tentang bagaimana async/await
dan asyncio
-seperti perpustakaan bekerja. Artinya, mungkin ada trik lain di atas (ada ...) tetapi trik itu tidak penting kecuali Anda membuatnya sendiri. Perbedaannya harus dapat diabaikan kecuali Anda sudah cukup tahu untuk tidak perlu mengajukan pertanyaan seperti itu.
1. Coroutine versus subrutin dalam kulit kacang
Sama seperti subrutin (fungsi, prosedur, ...), coroutine (generator, ...) adalah abstraksi tumpukan panggilan dan penunjuk instruksi: ada setumpuk potongan kode yang sedang dieksekusi, dan masing-masing berada pada instruksi tertentu.
Perbedaan def
versus async def
hanya untuk kejelasan. Perbedaan sebenarnya adalah return
versus yield
. Dari sini, await
atau yield from
ambil perbedaan dari panggilan individu ke seluruh tumpukan.
1.1. Subrutin
Sebuah subrutin mewakili tingkat tumpukan baru untuk menampung variabel lokal, dan satu traversal instruksi untuk mencapai tujuan. Pertimbangkan subrutin seperti ini:
def subfoo(bar):
qux = 3
return qux * bar
Saat Anda menjalankannya, itu artinya
- mengalokasikan ruang tumpukan untuk
bar
danqux
- jalankan pernyataan pertama secara rekursif dan lompat ke pernyataan berikutnya
- sekali per
return
, dorong nilainya ke stack pemanggil
- bersihkan tumpukan (1.) dan penunjuk instruksi (2.)
Khususnya, 4. berarti bahwa subrutin selalu dimulai pada keadaan yang sama. Segala sesuatu yang eksklusif untuk fungsi itu sendiri akan hilang setelah selesai. Suatu fungsi tidak dapat dilanjutkan, meskipun ada instruksi setelahnya return
.
root -\
: \- subfoo --\
:/--<---return --/
|
V
1.2. Coroutine sebagai subrutin yang persisten
Coroutine seperti subrutin, tetapi dapat keluar tanpa merusak statusnya. Pertimbangkan coroutine seperti ini:
def cofoo(bar):
qux = yield bar # yield marks a break point
return qux
Saat Anda menjalankannya, itu artinya
- mengalokasikan ruang tumpukan untuk
bar
danqux
- jalankan pernyataan pertama secara rekursif dan lompat ke pernyataan berikutnya
- sekali pada a
yield
, dorong nilainya ke stack pemanggil tetapi simpan stack dan penunjuk instruksi
- sekali memanggil
yield
, pulihkan tumpukan dan penunjuk instruksi dan dorong argumen kequx
- sekali per
return
, dorong nilainya ke stack pemanggil
- bersihkan tumpukan (1.) dan penunjuk instruksi (2.)
Perhatikan penambahan 2.1 dan 2.2 - coroutine dapat ditangguhkan dan dilanjutkan pada poin yang telah ditentukan. Ini mirip dengan bagaimana subrutin ditangguhkan selama pemanggilan subrutin lain. Perbedaannya adalah bahwa coroutine aktif tidak terikat secara ketat ke stack pemanggilnya. Sebaliknya, coroutine yang ditangguhkan adalah bagian dari tumpukan terpisah dan terisolasi.
root -\
: \- cofoo --\
:/--<+--yield --/
| :
V :
Ini berarti coroutine yang ditangguhkan dapat disimpan atau dipindahkan dengan bebas di antara tumpukan. Setiap tumpukan panggilan yang memiliki akses ke coroutine dapat memutuskan untuk melanjutkannya.
1.3. Melintasi tumpukan panggilan
Sejauh ini, coroutine kami hanya menggunakan call stack yield
. Sebuah subrutin bisa turun dan naik tumpukan panggilan dengan return
dan ()
. Untuk kelengkapan, coroutine juga membutuhkan mekanisme untuk menaikkan tumpukan panggilan. Pertimbangkan coroutine seperti ini:
def wrap():
yield 'before'
yield from cofoo()
yield 'after'
Ketika Anda menjalankannya, itu berarti masih mengalokasikan stack dan penunjuk instruksi seperti subrutin. Saat dihentikan, itu masih seperti menyimpan subrutin.
Namun, yield from
lakukan keduanya . Ini menangguhkan tumpukan dan penunjuk instruksi wrap
dan berjalan cofoo
. Perhatikan bahwa wrap
tetap ditangguhkan sampai cofoo
selesai sepenuhnya. Setiap kali cofoo
menangguhkan atau sesuatu dikirim, cofoo
langsung terhubung ke stack panggilan.
1.4. Coroutine sampai ke bawah
Seperti yang sudah mapan, yield from
memungkinkan untuk menghubungkan dua cakupan di satu lingkup perantara lainnya. Ketika diterapkan secara rekursif, itu berarti bagian atas tumpukan dapat dihubungkan ke bagian bawah tumpukan.
root -\
: \-> coro_a -yield-from-> coro_b --\
:/ <-+------------------------yield ---/
| :
:\ --+-- coro_a.send----------yield ---\
: coro_b <-/
Perhatikan itu root
dan coro_b
tidak tahu tentang satu sama lain. Ini membuat coroutine jauh lebih bersih daripada callback: coroutine masih dibangun di atas relasi 1: 1 seperti subrutin. Coroutine menangguhkan dan melanjutkan seluruh tumpukan eksekusi yang ada hingga titik panggilan biasa.
Khususnya, root
dapat memiliki jumlah coroutine yang berubah-ubah untuk dilanjutkan. Namun, itu tidak pernah bisa melanjutkan lebih dari satu pada waktu yang sama. Coroutine dari root yang sama bersifat bersamaan tetapi tidak paralel!
1.5. Python async
danawait
Penjelasannya sejauh ini secara eksplisit menggunakan kosakata yield
dan yield from
generator - fungsionalitas yang mendasarinya sama. Sintaks Python3.5 baru async
dan await
ada terutama untuk kejelasan.
def foo(): # subroutine?
return None
def foo(): # coroutine?
yield from foofoo() # generator? coroutine?
async def foo(): # coroutine!
await foofoo() # coroutine!
return None
The async for
dan async with
pernyataan diperlukan karena Anda akan memutus yield from/await
rantai dengan telanjang for
dan with
pernyataan.
2. Anatomi lingkaran peristiwa sederhana
Dengan sendirinya, coroutine tidak memiliki konsep untuk menyerahkan kendali ke coroutine lain . Itu hanya dapat menghasilkan kontrol ke pemanggil di bagian bawah tumpukan coroutine. Penelepon ini kemudian dapat beralih ke coroutine lain dan menjalankannya.
Node akar dari beberapa coroutine ini biasanya merupakan loop peristiwa : saat ditangguhkan, coroutine menghasilkan peristiwa yang ingin dilanjutkan. Pada gilirannya, loop peristiwa mampu menunggu peristiwa ini terjadi secara efisien. Ini memungkinkannya untuk memutuskan coroutine mana yang akan dijalankan berikutnya, atau bagaimana menunggu sebelum melanjutkan.
Desain seperti itu menyiratkan bahwa ada satu set peristiwa yang telah ditentukan sebelumnya yang dipahami oleh loop. Beberapa coroutine await
satu sama lain, sampai akhirnya ada event await
. Peristiwa ini dapat berkomunikasi secara langsung dengan perulangan peristiwa dengan yield
kontrol.
loop -\
: \-> coroutine --await--> event --\
:/ <-+----------------------- yield --/
| :
| : # loop waits for event to happen
| :
:\ --+-- send(reply) -------- yield --\
: coroutine <--yield-- event <-/
Kuncinya adalah penangguhan coroutine memungkinkan loop peristiwa dan peristiwa untuk berkomunikasi secara langsung. Tumpukan coroutine menengah tidak memerlukan pengetahuan apa pun tentang loop mana yang menjalankannya, atau cara kerja peristiwa.
2.1.1. Acara tepat waktu
Peristiwa paling sederhana untuk ditangani adalah mencapai suatu titik waktu. Ini adalah blok fundamental dari kode berulir juga: utas berulang kali sleep
sampai kondisi benar. Namun, sleep
eksekusi blok biasa dengan sendirinya - kami ingin coroutine lain tidak diblokir. Sebagai gantinya, kami ingin memberi tahu event loop kapan harus melanjutkan tumpukan coroutine saat ini.
2.1.2. Mendefinisikan Acara
Peristiwa hanyalah nilai yang dapat kita identifikasi - baik itu melalui enum, jenis atau identitas lainnya. Kita dapat mendefinisikan ini dengan kelas sederhana yang menyimpan waktu target kita. Selain menyimpan informasi acara, kami dapat mengizinkan ke await
kelas secara langsung.
class AsyncSleep:
"""Event to sleep until a point in time"""
def __init__(self, until: float):
self.until = until
# used whenever someone ``await``s an instance of this Event
def __await__(self):
# yield this Event to the loop
yield self
def __repr__(self):
return '%s(until=%.1f)' % (self.__class__.__name__, self.until)
Kelas ini hanya menyimpan acara - tidak mengatakan bagaimana sebenarnya menanganinya.
Satu-satunya fitur khusus adalah __await__
- itulah yang await
dicari kata kunci. Secara praktis, ini adalah iterator tetapi tidak tersedia untuk mesin iterasi biasa.
2.2.1. Menunggu acara
Sekarang kita punya acara, bagaimana reaksi coroutine terhadapnya? Kita harus bisa mengungkapkan padanannya sleep
dengan await
acara kita. Untuk lebih melihat apa yang sedang terjadi, kami menunggu dua kali untuk separuh waktu:
import time
async def asleep(duration: float):
"""await that ``duration`` seconds pass"""
await AsyncSleep(time.time() + duration / 2)
await AsyncSleep(time.time() + duration / 2)
Kita dapat langsung membuat instance dan menjalankan coroutine ini. Mirip dengan generator, menggunakan coroutine.send
menjalankan coroutine sampai yield
hasilnya.
coroutine = asleep(100)
while True:
print(coroutine.send(None))
time.sleep(0.1)
Ini memberi kita dua AsyncSleep
peristiwa dan kemudian StopIteration
saat coroutine selesai. Perhatikan bahwa satu-satunya penundaan adalah dari time.sleep
dalam loop! Masing-masing AsyncSleep
hanya menyimpan offset dari waktu saat ini.
2.2.2. Acara + Tidur
Pada titik ini, kami memiliki dua mekanisme terpisah yang kami miliki:
AsyncSleep
Peristiwa yang dapat dihasilkan dari dalam coroutine
time.sleep
yang dapat menunggu tanpa memengaruhi coroutine
Khususnya, keduanya ortogonal: tidak satu pun yang memengaruhi atau memicu yang lain. Alhasil, kita bisa memikirkan strategi kita sendiri sleep
untuk mengatasi keterlambatan sebuah AsyncSleep
.
2.3. Perulangan peristiwa yang naif
Jika kami memiliki beberapa coroutine, masing-masing dapat memberi tahu kami kapan ingin dibangunkan. Kemudian kita bisa menunggu sampai yang pertama ingin dilanjutkan, lalu yang setelahnya, dan seterusnya. Khususnya, di setiap titik kami hanya peduli tentang mana yang berikutnya .
Ini membuat penjadwalan menjadi mudah:
- urutkan coroutine berdasarkan waktu bangun yang diinginkan
- pilih yang pertama ingin bangun
- tunggu sampai saat ini
- jalankan coroutine ini
- ulangi dari 1.
Implementasi sepele tidak membutuhkan konsep lanjutan. A list
memungkinkan untuk mengurutkan coroutine berdasarkan tanggal. Menunggu adalah hal yang biasa time.sleep
. Menjalankan coroutine berfungsi seperti sebelumnya dengan coroutine.send
.
def run(*coroutines):
"""Cooperatively run all ``coroutines`` until completion"""
# store wake-up-time and coroutines
waiting = [(0, coroutine) for coroutine in coroutines]
while waiting:
# 2. pick the first coroutine that wants to wake up
until, coroutine = waiting.pop(0)
# 3. wait until this point in time
time.sleep(max(0.0, until - time.time()))
# 4. run this coroutine
try:
command = coroutine.send(None)
except StopIteration:
continue
# 1. sort coroutines by their desired suspension
if isinstance(command, AsyncSleep):
waiting.append((command.until, coroutine))
waiting.sort(key=lambda item: item[0])
Tentu saja, ini memiliki banyak ruang untuk perbaikan. Kita bisa menggunakan heap untuk antrian tunggu atau tabel pengiriman untuk acara. Kita juga bisa mengambil nilai kembali dariStopIteration
dan menetapkannya ke coroutine. Namun, prinsip dasarnya tetap sama.
2.4. Koperasi Menunggu
The AsyncSleep
event dan run
event loop adalah implementasi sepenuhnya bekerja peristiwa waktunya.
async def sleepy(identifier: str = "coroutine", count=5):
for i in range(count):
print(identifier, 'step', i + 1, 'at %.2f' % time.time())
await asleep(0.1)
run(*(sleepy("coroutine %d" % j) for j in range(5)))
Ini secara kooperatif beralih di antara masing-masing dari lima coroutine, menangguhkan masing-masing selama 0,1 detik. Meskipun event loop sinkron, event loop masih mengeksekusi pekerjaan dalam 0,5 detik, bukan 2,5 detik. Setiap coroutine memegang status dan bertindak secara independen.
3. Perulangan peristiwa I / O
Perulangan peristiwa yang mendukung sleep
cocok untuk polling . Namun, menunggu I / O pada pegangan file dapat dilakukan dengan lebih efisien: sistem operasi mengimplementasikan I / O dan dengan demikian mengetahui pegangan mana yang siap. Idealnya, event loop harus mendukung event eksplisit "ready for I / O".
3.1. The select
panggilan
Python sudah memiliki antarmuka untuk meminta OS membaca pegangan I / O. Saat dipanggil dengan tuas untuk membaca atau menulis, ia mengembalikan tuas siap untuk membaca atau menulis:
readable, writeable, _ = select.select(rlist, wlist, xlist, timeout)
Misalnya, kita dapat open
membuat file untuk ditulis dan menunggu sampai siap:
write_target = open('/tmp/foo')
readable, writeable, _ = select.select([], [write_target], [])
Setelah memilih kembali, writeable
berisi file terbuka kami.
3.2. Acara I / O dasar
Mirip dengan AsyncSleep
permintaan tersebut, kita perlu mendefinisikan acara untuk I / O. Dengan select
logika yang mendasarinya , acara tersebut harus mengacu pada objek yang dapat dibaca - misalnya open
file. Selain itu, kami menyimpan berapa banyak data untuk dibaca.
class AsyncRead:
def __init__(self, file, amount=1):
self.file = file
self.amount = amount
self._buffer = ''
def __await__(self):
while len(self._buffer) < self.amount:
yield self
# we only get here if ``read`` should not block
self._buffer += self.file.read(1)
return self._buffer
def __repr__(self):
return '%s(file=%s, amount=%d, progress=%d)' % (
self.__class__.__name__, self.file, self.amount, len(self._buffer)
)
Seperti AsyncSleep
kita kebanyakan hanya menyimpan data yang diperlukan untuk panggilan sistem yang mendasarinya. Kali ini, __await__
dapat dilanjutkan beberapa kali - sampai keinginan kita amount
telah terbaca. Selain itu, kami mendapatkan return
hasil I / O, bukan hanya melanjutkan.
3.3. Menambahkan event loop dengan read I / O
Basis untuk loop acara kami masih run
ditentukan sebelumnya. Pertama, kita perlu melacak permintaan baca. Ini bukan lagi jadwal yang diurutkan, kami hanya memetakan permintaan baca ke coroutine.
# new
waiting_read = {} # type: Dict[file, coroutine]
Karena select.select
mengambil parameter waktu tunggu, kita dapat menggunakannya sebagai pengganti time.sleep
.
# old
time.sleep(max(0.0, until - time.time()))
# new
readable, _, _ = select.select(list(reads), [], [])
Ini memberi kita semua file yang dapat dibaca - jika ada, kita menjalankan coroutine yang sesuai. Jika tidak ada, kita harus menunggu cukup lama agar coroutine kita saat ini berjalan.
# new - reschedule waiting coroutine, run readable coroutine
if readable:
waiting.append((until, coroutine))
waiting.sort()
coroutine = waiting_read[readable[0]]
Akhirnya, kami harus benar-benar mendengarkan permintaan baca.
# new
if isinstance(command, AsyncSleep):
...
elif isinstance(command, AsyncRead):
...
3.4. Menyatukannya
Di atas adalah sedikit penyederhanaan. Kita perlu melakukan beberapa peralihan ke pola tidur tidak kelaparan jika kita selalu bisa membaca. Kita perlu menangani tidak ada untuk dibaca atau tidak ada yang menunggu. Namun, hasil akhirnya masih cocok dengan 30 LOC.
def run(*coroutines):
"""Cooperatively run all ``coroutines`` until completion"""
waiting_read = {} # type: Dict[file, coroutine]
waiting = [(0, coroutine) for coroutine in coroutines]
while waiting or waiting_read:
# 2. wait until the next coroutine may run or read ...
try:
until, coroutine = waiting.pop(0)
except IndexError:
until, coroutine = float('inf'), None
readable, _, _ = select.select(list(waiting_read), [], [])
else:
readable, _, _ = select.select(list(waiting_read), [], [], max(0.0, until - time.time()))
# ... and select the appropriate one
if readable and time.time() < until:
if until and coroutine:
waiting.append((until, coroutine))
waiting.sort()
coroutine = waiting_read.pop(readable[0])
# 3. run this coroutine
try:
command = coroutine.send(None)
except StopIteration:
continue
# 1. sort coroutines by their desired suspension ...
if isinstance(command, AsyncSleep):
waiting.append((command.until, coroutine))
waiting.sort(key=lambda item: item[0])
# ... or register reads
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
3.5. Koperasi I / O
The AsyncSleep
, AsyncRead
dan run
implementasi yang sekarang sepenuhnya fungsional untuk tidur dan / atau membaca. Sama seperti sleepy
, kita bisa mendefinisikan helper untuk menguji bacaan:
async def ready(path, amount=1024*32):
print('read', path, 'at', '%d' % time.time())
with open(path, 'rb') as file:
result = return await AsyncRead(file, amount)
print('done', path, 'at', '%d' % time.time())
print('got', len(result), 'B')
run(sleepy('background', 5), ready('/dev/urandom'))
Dengan menjalankan ini, kita dapat melihat bahwa I / O kita diselingi dengan tugas menunggu:
id background round 1
read /dev/urandom at 1530721148
id background round 2
id background round 3
id background round 4
id background round 5
done /dev/urandom at 1530721148
got 1024 B
4. I / O Non-Pemblokiran
Sementara I / O pada file mendapatkan konsepnya, itu tidak benar-benar cocok untuk perpustakaan seperti asyncio
: select
panggilan selalu kembali untuk file , dan keduanya open
dan read
dapat memblokir tanpa batas . Ini memblokir semua coroutine dari sebuah event loop - yang buruk. Pustaka seperti aiofiles
menggunakan utas dan sinkronisasi ke I / O non-pemblokiran palsu dan acara di file.
Namun, soket memungkinkan untuk non-pemblokiran I / O - dan latensi yang melekat membuatnya jauh lebih kritis. Saat digunakan dalam event loop, menunggu data dan mencoba kembali dapat digabungkan tanpa memblokir apa pun.
4.1. Peristiwa I / O Non-Pemblokiran
Mirip dengan kami AsyncRead
, kami dapat menentukan acara suspend-and-read untuk soket. Alih-alih mengambil file, kami mengambil soket - yang tidak boleh memblokir. Juga, kami __await__
menggunakan, socket.recv
bukan file.read
.
class AsyncRecv:
def __init__(self, connection, amount=1, read_buffer=1024):
assert not connection.getblocking(), 'connection must be non-blocking for async recv'
self.connection = connection
self.amount = amount
self.read_buffer = read_buffer
self._buffer = b''
def __await__(self):
while len(self._buffer) < self.amount:
try:
self._buffer += self.connection.recv(self.read_buffer)
except BlockingIOError:
yield self
return self._buffer
def __repr__(self):
return '%s(file=%s, amount=%d, progress=%d)' % (
self.__class__.__name__, self.connection, self.amount, len(self._buffer)
)
Sebaliknya AsyncRead
, __await__
menjalankan I / O yang benar-benar tidak memblokir. Saat data tersedia, data selalu terbaca. Jika tidak ada data yang tersedia, itu selalu ditangguhkan. Itu berarti event loop hanya diblokir saat kami melakukan pekerjaan yang berguna.
4.2. Buka blokir event loop
Sejauh menyangkut loop acara, tidak banyak yang berubah. Peristiwa yang akan didengarkan masih sama dengan untuk file - deskriptor file yang ditandai siap oleh select
.
# old
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
# new
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
elif isinstance(command, AsyncRecv):
waiting_read[command.connection] = coroutine
Pada titik ini, harus jelas bahwa AsyncRead
dan AsyncRecv
merupakan jenis peristiwa yang sama. Kami dapat dengan mudah merefaktornya menjadi satu acara dengan komponen I / O yang dapat ditukar. Akibatnya, event loop, coroutine, dan event dengan rapi memisahkan penjadwal, kode perantara arbitrer dan I / O aktual.
4.3. Sisi jelek dari I / O non-pemblokiran
Pada prinsipnya, apa yang harus Anda lakukan saat ini adalah mereplikasi logika read
as a recv
for AsyncRecv
. Namun, ini jauh lebih buruk sekarang - Anda harus menangani pengembalian awal ketika fungsi memblokir di dalam kernel, tetapi kontrol hasil kepada Anda. Misalnya, membuka koneksi versus membuka file jauh lebih lama:
# file
file = open(path, 'rb')
# non-blocking socket
connection = socket.socket()
connection.setblocking(False)
# open without blocking - retry on failure
try:
connection.connect((url, port))
except BlockingIOError:
pass
Singkat cerita, yang tersisa adalah beberapa lusin baris penanganan Exception. Peristiwa dan loop peristiwa sudah berfungsi pada saat ini.
id background round 1
read localhost:25000 at 1530783569
read /dev/urandom at 1530783569
done localhost:25000 at 1530783569 got 32768 B
id background round 2
id background round 3
id background round 4
done /dev/urandom at 1530783569 got 4096 B
id background round 5
Tambahan
Contoh kode di github
BaseEventLoop
diimplementasikan: github.com/python/cpython/blob/…