Seperti yang saya pahami yield
kata kunci, jika digunakan dari dalam blok iterator, ia mengembalikan aliran kontrol ke kode panggilan, dan ketika iterator dipanggil lagi, ia mengambil di mana ia tinggalkan.
Juga, await
tidak hanya menunggu callee, tetapi mengembalikan kontrol ke pemanggil, hanya untuk mengambil di mana ia tinggalkan saat pemanggil awaits
metode.
Dengan kata lain-- tidak ada utas , dan "konkurensi" async dan menunggu adalah ilusi yang disebabkan oleh aliran kontrol yang cerdas, detailnya disembunyikan oleh sintaksis.
Sekarang, saya adalah mantan programmer perakitan dan saya sangat akrab dengan petunjuk instruksi, tumpukan, dll. Dan saya mendapatkan cara kerja normal aliran kontrol (subrutin, rekursi, loop, cabang). Tapi konstruksi baru ini-- Saya tidak mengerti.
Ketika sebuah await
tercapai, bagaimana runtime mengetahui bagian kode apa yang harus dieksekusi selanjutnya? Bagaimana cara mengetahui kapan dapat dilanjutkan dari tempat yang ditinggalkannya, dan bagaimana ia mengingat di mana? Apa yang terjadi pada tumpukan panggilan saat ini, apakah itu disimpan entah bagaimana? Bagaimana jika metode panggilan membuat panggilan metode lain sebelum itu - await
mengapa tumpukan tidak ditimpa? Dan bagaimana mungkin runtime bekerja melalui semua ini dalam kasus pengecualian dan tumpukan terlepas?
Ketika yield
tercapai, bagaimana runtime melacak titik di mana hal-hal harus diambil? Bagaimana status iterator dipertahankan?
sumber
Jawaban:
Saya akan menjawab pertanyaan spesifik Anda di bawah ini, tetapi sebaiknya Anda membaca artikel ekstensif saya tentang bagaimana kami merancang hasil dan menunggu.
https://blogs.msdn.microsoft.com/ericlippert/tag/continuation-passing-style/
https://blogs.msdn.microsoft.com/ericlippert/tag/iterators/
https://blogs.msdn.microsoft.com/ericlippert/tag/async/
Beberapa dari artikel ini sudah usang sekarang; kode yang dihasilkan berbeda dalam banyak hal. Tapi ini pasti akan memberi Anda gambaran tentang cara kerjanya.
Juga, jika Anda tidak memahami bagaimana lambda dibuat sebagai kelas penutup, pahami itu terlebih dahulu . Anda tidak akan membuat kepala atau ekor menjadi asinkron jika Anda tidak memiliki lambda ke bawah.
await
dihasilkan sebagai:Itu pada dasarnya. Menunggu hanyalah pengembalian yang mewah.
Nah, bagaimana Anda melakukannya tanpa menunggu? Ketika metode foo memanggil bilah metode, entah bagaimana kami ingat bagaimana kembali ke tengah-tengah foo, dengan semua penduduk lokal aktivasi foo utuh, tidak peduli bilah apa yang dilakukannya.
Anda tahu bagaimana hal itu dilakukan di assembler. Catatan aktivasi untuk foo didorong ke stack; itu mengandung nilai-nilai penduduk setempat. Pada titik panggilan, alamat pengirim di foo didorong ke stack. Ketika bilah selesai, penunjuk tumpukan dan penunjuk instruksi disetel ulang ke tempat yang mereka butuhkan dan foo terus berjalan dari tempat terakhirnya.
Kelanjutan dari menunggu persis sama, kecuali bahwa record diletakkan di heap karena alasan yang jelas bahwa urutan aktivasi tidak membentuk stack .
Delegasi yang menunggu diberikan sebagai kelanjutan dari tugas berisi (1) angka yang merupakan masukan ke tabel pencarian yang memberikan penunjuk instruksi yang perlu Anda jalankan selanjutnya, dan (2) semua nilai lokal dan temporari.
Ada beberapa perlengkapan tambahan di sana; misalnya, di .NET adalah ilegal untuk bercabang di tengah blok percobaan, jadi Anda tidak bisa begitu saja memasukkan alamat kode di dalam blok percobaan ke dalam tabel. Tapi ini adalah detail pembukuan. Secara konseptual, catatan aktivasi hanya dipindahkan ke heap.
Informasi yang relevan dalam catatan aktivasi saat ini tidak pernah diletakkan di tumpukan sejak awal; itu dialokasikan dari heap sejak awal. (Nah, parameter formal diteruskan di stack atau di register secara normal dan kemudian disalin ke lokasi heap saat metode dimulai.)
Catatan aktivasi penelepon tidak disimpan; penantian mungkin akan kembali kepada mereka, ingat, jadi mereka akan ditangani secara normal.
Perhatikan bahwa ini adalah perbedaan erat antara gaya penerusan lanjutan yang disederhanakan dari menunggu, dan struktur panggilan-dengan-kelanjutan yang sebenarnya yang Anda lihat dalam bahasa seperti Skema. Dalam bahasa-bahasa tersebut seluruh kelanjutan termasuk kelanjutan kembali ke pemanggil ditangkap oleh panggilan-cc .
Panggilan metode tersebut kembali, sehingga catatan aktivasi mereka tidak lagi berada di tumpukan pada titik menunggu.
Jika terjadi pengecualian yang tidak tertangkap, pengecualian tersebut ditangkap, disimpan di dalam tugas, dan ditampilkan kembali saat hasil tugas diambil.
Ingat semua pembukuan yang saya sebutkan sebelumnya? Mendapatkan pengecualian semantik dengan benar adalah rasa sakit yang luar biasa, izinkan saya memberi tahu Anda.
Cara yang sama. Status penduduk setempat dipindahkan ke heap, dan nomor yang mewakili instruksi yang
MoveNext
harus dilanjutkan saat dipanggil berikutnya disimpan bersama dengan penduduk setempat.Dan lagi, ada banyak roda gigi di blok iterator untuk memastikan bahwa pengecualian ditangani dengan benar.
sumber
yield
lebih mudah dari keduanya, jadi mari kita periksa.Katakanlah kita punya:
Ini akan dikompilasi sedikit seperti jika kita menulis:
Jadi, tidak seefisien implementasi tulisan tangan
IEnumerable<int>
danIEnumerator<int>
(misalnya kita tidak akan menyia-nyiakan memiliki yang terpisah_state
,_i
dan_current
dalam hal ini) tetapi tidak buruk (trik menggunakan kembali sendiri ketika aman untuk melakukannya daripada membuat yang baru object is good), dan dapat dikembangkan untuk menanganiyield
metode -menggunakan yang sangat rumit .Dan tentu saja sejak itu
Sama dengan:
Kemudian yang dihasilkan
MoveNext()
dipanggil berulang kali.The
async
kasus ini cukup banyak prinsip yang sama, tetapi dengan sedikit kompleksitas ekstra. Untuk menggunakan kembali contoh dari kode jawaban lain seperti:Menghasilkan kode seperti:
Ini lebih rumit, tetapi prinsip dasarnya sangat mirip. Komplikasi tambahan utama adalah yang sekarang
GetAwaiter()
digunakan. Jika suatu saatawaiter.IsCompleted
dicentang, ia kembalitrue
karena tugasawait
sudah selesai (misalnya kasus di mana ia bisa kembali secara sinkron) maka metode akan terus bergerak melalui status, tetapi sebaliknya ia menetapkan dirinya sendiri sebagai panggilan balik ke awaiter.Apa yang terjadi dengan itu tergantung pada awaiter, dalam hal apa yang memicu callback (misalnya penyelesaian I / O asinkron, tugas yang berjalan pada penyelesaian thread) dan persyaratan apa yang ada untuk marshalling ke utas tertentu atau berjalan di utas threadpool , konteks apa dari panggilan asli yang mungkin diperlukan atau tidak diperlukan dan seterusnya. Apapun itu meskipun sesuatu dalam penunggu itu akan memanggil
MoveNext
dan itu akan melanjutkan dengan bagian pekerjaan berikutnya (hingga berikutnyaawait
) atau selesai dan kembali dalam halTask
yang sedang dilaksanakan menjadi selesai.sumber
yield
ke manual ketika ada keuntungan dalam melakukannya (umumnya sebagai pengoptimalan, tetapi ingin memastikan bahwa titik awalnya dekat dengan yang dihasilkan kompiler jadi tidak ada yang menjadi tidak dioptimalkan melalui asumsi buruk). Yang kedua pertama kali digunakan dalam jawaban lain dan ada beberapa celah dalam pengetahuan saya pada saat itu, jadi saya mendapat manfaat dari mengisi jawaban tersebut sambil memberikan jawaban itu dengan menguraikan kode secara manual.Ada banyak sekali jawaban bagus di sini; Saya hanya akan membagikan beberapa sudut pandang yang dapat membantu membentuk model mental.
Pertama,
async
metode dipecah menjadi beberapa bagian oleh kompilator; yangawait
ekspresi adalah poin fraktur. (Ini mudah dipahami untuk metode sederhana; metode yang lebih kompleks dengan loop dan penanganan pengecualian juga terputus, dengan penambahan mesin status yang lebih kompleks).Kedua,
await
diterjemahkan ke dalam urutan yang cukup sederhana; Saya suka deskripsi Lucian , yang dengan kata lain cukup banyak "jika menunggu sudah selesai, dapatkan hasilnya dan lanjutkan menjalankan metode ini; jika tidak, simpan status metode ini dan kembalikan". (Saya menggunakan terminologi yang sangat mirip diasync
intro saya ).Sisa dari metode ini ada sebagai callback untuk yang menunggu (dalam kasus tugas, callback ini adalah kelanjutan). Saat awaitable selesai, ia memanggil callback-nya.
Perhatikan bahwa tumpukan panggilan tidak disimpan dan dipulihkan; callback dipanggil secara langsung. Dalam kasus I / O yang tumpang tindih, mereka dipanggil langsung dari kumpulan thread.
Callback tersebut dapat terus mengeksekusi metode secara langsung, atau menjadwalkannya untuk dijalankan di tempat lain (misalnya, jika
await
UISynchronizationContext
dan I / O yang diambil selesai di kumpulan thread).Itu semua hanya panggilan balik. Saat awaitable selesai, ia memanggil callback-nya, dan apa saja
async
metode yang telahawait
mengubahnya akan dilanjutkan. Callback melompat ke tengah metode itu dan memiliki variabel lokalnya dalam cakupan.Callback tidak menjalankan utas tertentu, dan tidak memiliki callstack mereka dipulihkan.
Callstack tidak disimpan sejak awal; itu tidak perlu.
Dengan kode sinkron, Anda bisa mendapatkan tumpukan panggilan yang menyertakan semua pemanggil Anda, dan runtime tahu ke mana harus kembali menggunakannya.
Dengan kode asinkron, Anda dapat berakhir dengan sekumpulan penunjuk panggilan balik - berakar pada beberapa operasi I / O yang menyelesaikan tugasnya, yang dapat melanjutkan
async
metode yang menyelesaikan tugasnya, yang dapat melanjutkanasync
metode yang menyelesaikan tugasnya, dll.Jadi, dengan sinkron kode
A
panggilanB
panggilanC
, callstack Anda mungkin terlihat seperti ini:sedangkan kode asynchronous menggunakan callback (pointer):
Saat ini, agak tidak efisien. :)
Ia bekerja seperti lambda lainnya - masa pakai variabel diperpanjang dan referensi ditempatkan ke dalam objek status yang hidup di tumpukan. Sumber daya terbaik untuk semua detail tingkat dalam adalah seri EduAsync dari Jon Skeet .
sumber
yield
danawait
, meskipun keduanya berurusan dengan kontrol aliran, dua hal yang sama sekali berbeda. Jadi saya akan menangani mereka secara terpisah.Tujuannya
yield
adalah untuk mempermudah pembuatan urutan pemalas. Saat Anda menulis enumerator-loop denganyield
pernyataan di dalamnya, kompilator menghasilkan banyak kode baru yang tidak Anda lihat. Di bawah tenda, itu benar-benar menghasilkan kelas yang sama sekali baru. Kelas berisi anggota yang melacak status loop, dan implementasi IEnumerable sehingga setiap kali Anda memanggilnyaMoveNext
, langkah-langkah sekali lagi melalui loop itu. Jadi saat Anda melakukan foreach loop seperti ini:kode yang dihasilkan terlihat seperti ini:
Di dalam implementasi mything.items () adalah sekumpulan kode mesin negara yang akan melakukan satu "langkah" dari loop kemudian kembali. Jadi, saat Anda menulisnya di sumber seperti loop sederhana, di balik terpal itu bukan loop sederhana. Jadi tipu muslihat kompiler. Jika Anda ingin melihat diri Anda sendiri, tarik ILDASM atau ILSpy atau alat serupa dan lihat seperti apa IL yang dihasilkan. Itu harus instruktif.
async
danawait
, di sisi lain, adalah ketel ikan lainnya. Await adalah, secara abstrak, primitif sinkronisasi. Ini adalah cara untuk memberi tahu sistem "Saya tidak bisa melanjutkan sampai ini selesai." Tapi, seperti yang Anda catat, tidak selalu ada utas yang terlibat.Apa yang terlibat adalah sesuatu yang disebut konteks sinkronisasi. Selalu ada orang yang berkeliaran. Pekerjaan konteks sinkronisasi mereka adalah menjadwalkan tugas yang sedang ditunggu dan kelanjutannya.
Saat Anda mengatakan
await thisThing()
, beberapa hal terjadi. Dalam metode async, kompilator benar-benar memotong metode menjadi potongan yang lebih kecil, setiap potongan menjadi bagian "sebelum menunggu" dan bagian "setelah menunggu" (atau lanjutan). Saat await dijalankan, tugas sedang ditunggu, dan kelanjutan berikut - dengan kata lain, fungsi lainnya - diteruskan ke konteks sinkronisasi. Konteks menangani penjadwalan tugas, dan ketika selesai konteks kemudian menjalankan kelanjutan, meneruskan nilai kembali apa pun yang diinginkannya.Konteks sinkronisasi bebas untuk melakukan apa pun yang diinginkan selama itu menjadwalkan hal-hal. Itu bisa menggunakan kumpulan benang. Itu bisa membuat utas per tugas. Itu bisa menjalankannya secara sinkron. Lingkungan yang berbeda (ASP.NET vs. WPF) menyediakan implementasi konteks sinkronisasi berbeda yang melakukan hal berbeda berdasarkan apa yang terbaik untuk lingkungannya.
(Bonus: pernah bertanya-tanya apa
.ConfigurateAwait(false)
fungsinya? Ini memberi tahu sistem untuk tidak menggunakan konteks sinkronisasi saat ini (biasanya berdasarkan jenis proyek Anda - WPF vs ASP.NET misalnya) dan sebagai gantinya gunakan yang default, yang menggunakan kumpulan utas).Jadi sekali lagi, ini banyak tipuan kompiler. Jika Anda melihat kode yang dihasilkan itu rumit tetapi Anda harus dapat melihat apa yang dilakukannya. Jenis transformasi ini sulit, tetapi deterministik dan matematis, itulah mengapa sangat bagus bahwa kompiler melakukannya untuk kita.
PS Ada satu pengecualian untuk keberadaan konteks sinkronisasi default - aplikasi konsol tidak memiliki konteks sinkronisasi default. Periksa blog Stephen Toub untuk informasi lebih lanjut. Ini adalah tempat yang tepat untuk mencari informasi
async
danawait
secara umum.sumber
Biasanya, saya akan merekomendasikan untuk melihat CIL, tetapi dalam kasus ini, itu berantakan.
Kedua konstruksi bahasa ini serupa dalam bekerja, tetapi diterapkan sedikit berbeda. Pada dasarnya, ini hanya gula sintaksis untuk sihir kompiler, tidak ada yang gila / tidak aman di tingkat perakitan. Mari kita lihat secara singkat.
yield
adalah pernyataan yang lebih lama dan lebih sederhana, dan ini adalah gula sintaksis untuk mesin status dasar. Sebuah metode yang kembaliIEnumerable<T>
atauIEnumerator<T>
mungkin berisiyield
, yang kemudian mengubah metode tersebut menjadi pabrik mesin keadaan. Satu hal yang harus Anda perhatikan adalah bahwa tidak ada kode dalam metode ini yang dijalankan saat Anda memanggilnya, jika adayield
di dalamnya. Alasannya adalah bahwa kode yang Anda tulis dipindahkan keIEnumerator<T>.MoveNext
metode, yang memeriksa statusnya dan menjalankan bagian kode yang benar.yield return x;
kemudian diubah menjadi sesuatu yang mirip denganthis.Current = x; return true;
Jika Anda melakukan refleksi, Anda dapat dengan mudah memeriksa mesin negara bagian yang dibangun dan bidangnya (setidaknya satu untuk negara bagian dan untuk penduduk setempat). Anda bahkan dapat mengatur ulang jika Anda mengubah bidang.
await
membutuhkan sedikit dukungan dari pustaka tipe, dan bekerja agak berbeda. Dibutuhkan argumenTask
atauTask<T>
, lalu hasilnya sesuai nilainya jika tugas selesai, atau mendaftarkan kelanjutan melaluiTask.GetAwaiter().OnCompleted
. Penerapanasync
/await
system secara penuh akan memakan waktu terlalu lama untuk dijelaskan, tetapi juga tidak terlalu mistis. Ini juga membuat mesin status dan meneruskannya di sepanjang lanjutan ke OnCompleted . Jika tugas selesai, itu kemudian menggunakan hasilnya di kelanjutan. Pelaksanaan awaiter memutuskan bagaimana meminta kelanjutan. Biasanya ini menggunakan konteks sinkronisasi utas panggilan.Keduanya
yield
danawait
harus membagi metode berdasarkan kemunculannya untuk membentuk mesin status, dengan setiap cabang mesin mewakili setiap bagian dari metode.Anda tidak boleh memikirkan konsep ini dalam istilah "tingkat bawah" seperti tumpukan, utas, dll. Ini adalah abstraksi, dan cara kerja dalamnya tidak memerlukan dukungan apa pun dari CLR, hanya kompiler yang melakukan keajaiban. Ini sangat berbeda dari coroutine Lua, yang memiliki dukungan runtime, atau longjmp C , yang hanya sihir hitam.
sumber
await
tidak harus mengambil Tugas . Apa pun dengan ituINotifyCompletion GetAwaiter()
sudah cukup. Agak mirip dengan bagaimanaforeach
tidak perluIEnumerable
, apapun denganIEnumerator GetEnumerator()
sudah cukup.