Prosesor multi-inti adalah hal biasa. Cepat atau lambat, setiap programmer praktis harus pergi ke labirin pemrograman multi-threaded dan bertemu dengan "monster" yang menghuninya. Mari kita bicara tentang di mana memulai dengan cara ini dan alat dan pendekatan apa yang akan membantu untuk menang Saya membuat laporan ini untuk para peserta masa depan dalam
magang sepanjang tahun Yandex.
- Nama saya Seva Minkov. Saya bekerja di departemen infrastruktur cloud departemen pencarian. Saya terutama berurusan dengan backend. Saya menulis dalam berbagai bahasa, tetapi yang paling sering adalah Java dan bahasa yang berjalan di Java Virtual Machine (JVM).
Tim kami sedang mengembangkan cloud internal di mana hampir semua layanan Yandex diluncurkan - keduanya dikenal publik seperti Search, Mail dan Alice, serta berbagai layanan internal, mesin virtual, serta tugas MapReduce yang berumur pendek dan tugas pembelajaran mesin.
Cloud kami tidak statis: perusahaan berkembang, jumlah layanan dan sumber daya yang mereka konsumsi meningkat. Dan tim kami sangat sering menghadapi tantangan peningkatan dan peningkatan kinerja. Kami mencapai ini dengan menggunakan semua alat yang tersedia, termasuk penskalaan vertikal - yaitu, dengan mempercepat komponen individu sistem hingga menulis ulang beberapa algoritma berulir tunggal sehingga mereka bekerja lebih cepat. Kami melakukan penskalaan horizontal: memecah sistem menjadi bagian-bagian kecil untuk mencapai kinerja yang lebih baik dengan menambahkan server, prosesor, inti, dll.
Dan pemrograman multi-threaded banyak membantu kita dalam hal ini. Kami akan membicarakannya hari ini - dari mana asalnya, mengapa itu relevan; apa model memori, dan bagaimana itu umumnya diwakili di Jawa. Kami akan menyentuh beberapa aspek praktis tentang cara menguji aplikasi Anda dan memverifikasi kebenarannya.

Untuk memulai, mari kita lihat pada grafik yang menarik ini, yang menunjukkan tren karakteristik mikroprosesor selama 40 tahun terakhir. Sekitar 10-15 tahun yang lalu, ketika rumput lebih hijau dan prosesornya single-threaded, seorang programmer biasa bisa sekali menulis program single-threaded yang benar, dan kemudian mengandalkan hukum empiris Moore. Dia mengatakan bahwa prosesor dua kali lebih cepat setiap dua tahun. Seperti yang Anda lihat, sekitar tahun 2005, karena berbagai alasan, produsen mikroprosesor beralih ke arsitektur multi-core dan mulai meningkatkan jumlah core logis. Dan perolehan kinerja satu inti berhenti mematuhi hukum Moore, dan kekuatan pemrosesan satu inti mulai tumbuh lebih lambat. Ini membuat revolusi, dan programmer biasa harus menggunakan pemrograman paralel untuk menggunakan keuntungan kinerja yang sangat ini.
Karena kami sedang berlatih, kami akan mencoba menulis program multi-utas sederhana dan melihat sendiri bagaimana kerjanya.

Sebagai contoh, mari kita ambil tugas yang cukup sederhana untuk membaca catatan silang. Mari kita memiliki dua variabel bersama X dan Y, pertama diinisialisasi dengan nilai default (nol), dan dua aliran. Setiap utas menulis ke satu variabel dan membaca yang lain. Dalam kasus ini, Thread1 menulis unit dalam X dan membaca Y. Thread kedua melakukan hal yang sama, hanya mundur.
Implementasi Java sederhana mungkin terlihat seperti ini.

Kami akan menulis kelas ReadWriteTest, ia akan memiliki dua variabel statis X dan Y. Langsung dalam metode utama, kami membangun dua thread Thread1 dan Thread2, memberikan masing-masing dari mereka input beberapa fungsi lambda yang akan dieksekusi pada saat thread dijalankan. Letakkan kode dari slide sebelumnya di sana dan mulai dua utas.
Urutan awal utas, dalam beberapa hal, tidak dapat diprediksi. Itu tergantung pada bagaimana sistem operasi memasang benang. Dengan demikian, kita dapat memiliki berbagai versi. Tampaknya mengerti bagaimana ini semua bekerja, kita harus menjalankan program ini berkali-kali, kemudian mengagregasikan hasilnya dan melihat seberapa sering jawaban ini atau itu akan ditemukan dalam program.

Agar tidak menemukan kembali roda, kita dapat menggunakan alat yang sudah jadi. Ini disebut jcstress, Java Concurrency Stress menguji utilitas yang merupakan bagian dari proyek OpenJDK.
Utilitas ini menyediakan beberapa kerangka kerja untuk menulis tes stres. Dalam hal ini, kode dari slide sebelumnya cukup mudah ditulis ulang. Pertama-tama, kita akan menggantung anotasi Tes jcstress di kelas, yang hanya membuat skrip uji kita terlihat oleh utilitas. Kami juga menandainya dengan kelas State, yang mengatakan bahwa kelas tersebut berisi data yang dapat berubah: keduanya dimodifikasi dan dibaca dari aliran yang berbeda. Kami mendeklarasikan dua metode, thread1 dan thread2, dan menandainya dengan anotasi Aktor. Aktor anotasi berarti bahwa metode tersebut harus dieksekusi di utas terpisah. jcstress menjamin bahwa setiap metode tersebut akan dieksekusi dalam utas terpisah pada tepat satu instance dari kelas State. Urutan peluncurannya tidak ditentukan secara spesifik. Dan hasilnya akan ditulis ke beberapa objek II_Result yang ditampilkan pada slide. Kita dapat berasumsi bahwa ini adalah dua nilai numerik, yang disajikan hanya dengan metode Injeksi Ketergantungan, yang dibicarakan Cyril dalam laporan sebelumnya.
Sebelum memulai tes ini, mari kita pikirkan kesimpulan apa yang bisa diberikan oleh perintah dan nilai apa yang bisa kita tambahkan di r1 dan di r2.

Untuk melakukan ini, kami menggunakan model alternatif yang disebut. Satu atau lain cara, masing-masing operasi: membaca atau menulis, dilakukan dalam beberapa urutan. Cukup hanya dengan menggabungkan semua opsi ini dan melihat hasil apa yang kita miliki.

Misalkan salah satu varian acara yang mungkin adalah thread satu benar-benar dieksekusi sebelum thread kedua. Pertama, kami menambahkan satu ke X, baca nol dari Y, karena tidak ada entri. Kemudian mereka menulis satu ke Y dan membaca satu dari X, karena aliran pertama sudah berhasil melakukan ini.
Jawaban pertama adalah nol satu.

Varian kedua dari pengembangan acara justru sebaliknya: stream dua dieksekusi di depan stream satu.

Dengan demikian, kita mendapatkan hasil mirror satu-nol.

Ada sekitar empat opsi lagi yang memberikan hasil yang sama ketika kita memiliki eksekusi thread yang benar-benar bingung. Misalnya, kami mencatat unit dalam satu aliran di X, di aliran kedua kami berhasil memiliki unit di Y, dan kami menghitung satu-satu. Anda kemudian dapat melihat opsi lain apa yang ada sebagai latihan di rumah.

Tampaknya kami telah membahas semua opsi yang mungkin, tidak ada lagi. Mari kita jalankan utilitas dan lihat apa kesimpulannya.

Outputnya terlihat seperti tabel. Kolom pertama berisi daftar hasil yang kami tambahkan di II_Result - utilitas menjalankan kode ini jutaan kali - dan jumlah kasus ketika hasil tertentu ditemukan sama sekali. Tetapi mungkin laporan ini tidak akan terjadi jika semuanya begitu sederhana.
Bahkan, dalam kesimpulan ini kita juga bisa melihat hasil nol-nol, yang sulit dijelaskan dengan model pergantian. Tampaknya salah satu opsi yang mungkin adalah seseorang secara langsung dalam kode aliran mengambil dan mengatur ulang baris.
Mari kita pikirkan mengapa itu terjadi dan bagaimana kita bisa hidup dengannya. Saya juga meminta Anda untuk memperhatikan fakta bahwa opsi satu-satu ditemukan secara khusus pada mesin saya sangat jarang. Dari 130 juta pertunjukan, hanya 154 pertunjukan yang menghasilkan satu-satu. Dan sebaliknya, nol-nol terjadi sangat sering, dalam hampir 30% kasus.

Jadi, untuk meringkas beberapa hasil antara yang kami semua lihat bersama Anda. Pertama-tama, kita dapat memahami bahwa interaksi aliran melalui memori tidak bersifat trivial. Model rotasi yang kami gunakan tidak berfungsi. Kami melihat penataan ulang. Itu bisa terjadi karena berbagai alasan.
Misalnya, kita bisa melihat "efek relativistik" dari besi. Ini dapat dipikirkan dalam uraian berikut: dalam satu siklus clock dari prosesor 3-GHz, cahaya bergerak sekitar 10 cm dalam ruang hampa. Protokol untuk membaca dan menulis ke memori prosesor adalah rumit dan kadang-kadang dibutuhkan ratusan siklus clock untuk mentransfer nilai dari satu inti ke inti lainnya. Dengan demikian, satu inti tampaknya dapat melihat masa lalu. Hasil setelah rekaman terjadi, tetapi kami melihat nilai lama. Selain itu, prosesor juga tidak tinggal diam dan dapat mengubah instruksi di tempat.
Kompiler pengoptimal modern dapat mengarah ke permutasi yang sama. Untuk mencapai kinerja single-threaded maksimum, mereka juga dapat bertukar instruksi sehingga ini tidak merusak kebenaran dari program single-threaded. Namun dalam program multi-utas dapat menimbulkan efek menarik yang telah kita lihat.
Dan yang kedua - mungkin kesimpulan utama: kami melihat bahwa program multithread secara fundamental tidak ditentukan. Program single-threaded terutama mengandalkan beberapa invarian pada input dan output, dan bersifat deterministik; mengingat bahwa generator angka acak dan input pengguna adalah parameter input.
Ini membuat segalanya menjadi sangat rumit: sulit untuk memahami apa yang dilakukan oleh program, dan sulit untuk mengujinya.
Tentang kerumitan pengujian, kami dapat menambahkan bahwa hasil yang sama ditemukan hanya 154 kali dari 130 juta panggilan. Peluang terjadinya hasil ini adalah sepersejuta. Dalam produksi, ini berarti bug semacam itu dapat direproduksi setelah berminggu-minggu. Dan itu pasti akan terjadi di suatu tempat pada Minggu malam, ketika Anda tidak mengharapkan ini sama sekali.

Mari kita pikirkan bagaimana kita seharusnya dan apa yang umumnya kita inginkan dari lidah kita untuk tidur nyenyak pada Minggu malam. Pertama, kita membutuhkan alat yang memungkinkan kita untuk memprediksi perilaku program dan membuat penilaian tentang pelaksanaannya. Kedua, kita membutuhkan alat bahasa yang akan memungkinkan kita untuk mempengaruhi permutasi dan efek - mereka dapat dari perangkat keras, kompiler, dll. Saya ingin tahu lebih sedikit tentang cara kerja prosesor tertentu, optimasi apa yang dapat dilakukan oleh kompiler, dan menggunakan singkatan yang datang dari dunia Jawa. Write Once, Run Anywhere - tulis kode multi-thread yang benar sehingga bisa digunakan di semua platform.

Pertanyaan dan persyaratan ini yang telah kami daftarkan, mereka muncul di benak pengembang untuk waktu yang sangat lama dan para ahli teori dan praktisi. Seperti halnya tugas rumit dengan tingkat kerumitan yang tinggi, itu diselesaikan dengan memperkenalkan konsep beberapa mesin abstrak. Kita semua, pengembang dalam bahasa pemrograman tingkat tinggi, tidak menulis untuk perangkat keras tertentu, tidak untuk model prosesor seperti itu, tetapi menulis beberapa mesin abstrak. Dan spesifikasi bahasa dirancang untuk menggambarkan perilakunya sedemikian rupa untuk merekonsiliasi tiga dunia ini. Di satu sisi, biarkan pengembang kompiler dan prosesor melakukan optimisasi dan dengan ringan meniupkan otak mereka kepada kami, programmer yang sudah menulis dalam bahasa tertentu.
Model memori menempati posisi sentral dalam mesin abstrak ini. Dia harus menjawab satu pertanyaan: jika saya membaca variabel X dalam aliran tertentu, hasil entri mana yang bisa saya lihat di sana? Upaya memformalkan model memori dilakukan untuk pertama kalinya dalam bahasa Java, semua model memori lain muncul kemudian. Katakanlah C ++ 11 hampir merupakan copy paste dari model memori Java dengan beberapa perubahan.
Ada beberapa model memori di Jawa. Awalnya, model memori yang disebut "lonceng-berbentuk", itu diakui sebagai tidak berhasil, karena menghambat pekerjaan programmer yang menulis di Jawa, dan melarang beberapa optimasi ke kompiler, yang cukup sesuai untuk diri mereka sendiri. Dengan demikian, sebagai bagian dari proses komunitas JSR-133, model memori modern ditulis.
Karena kita memiliki tulisan suci dalam bentuk spesifikasi, mari kita coba melihat ke dalamnya dan memahami apa yang sebenarnya terjadi di dalam.

Ada beberapa masalah. Angkat tangan Anda, yang membuka spesifikasi bahasa dan membaca apa yang terjadi di sana. Dan berapa banyak dari Anda yang telah membaca hingga model memori paragraf 17.4? Kejutan kecil menanti Anda. Spesifikasi bahasa pada dasarnya dijelaskan dalam bahasa yang cukup dimengerti. Tetapi model memori penuh, katakanlah, beberapa hardcore matematika. Ada inklusi dalam bahasa Yunani, banyak istilah matematika dari seri transitif closure, penyatuan dua perintah, dll.
Sayangnya, tidak ada cara lain. Satu-satunya hal yang dapat Anda andalkan saat menulis program multithread adalah spesifikasinya. Dia harus membaca dan mengerti. Saya sangat merekomendasikan Anda. Terlebih lagi, ketika saya pertama kali membaca spesifikasinya, saya memiliki kesan seperti itu.
Mengapa begitu sulit? Saya salah jalan dan saya memperingatkan Anda untuk bertindak seperti saya.
Saya mengambilnya, mencari di internet apa model memori. Menemukan sebuah buku berjudul JSR-133 Cookbook for Compiler Writers. Dia menjelaskan bagaimana pengembang kompiler dapat mengimplementasikan model memori ini dengan cara yang sederhana. Masalahnya adalah ini adalah satu implementasi spesifik, dan tidak dapat digunakan untuk menilai keseluruhan model memori secara umum.
Pokoknya, mari kita mencoba membuat usaha kecil pada kesimpulan utama yang dapat dipahami dari model memori Java.

Mungkin ada banyak eksekusi program multithreaded Anda. Kami sendiri melihat ini pada contoh program kami sebelumnya. Dalam contoh paling sederhana, kami sudah memiliki empat hasil penerapannya. Dan tugas model memori Java adalah mengatakan eksekusi mana yang benar, dan mana yang harus dilarang. Dan mendalilkan tiga hal. Yang pertama adalah bahwa dalam kerangka satu utas tugas Anda dieksekusi pseudo-berurutan. Ini menyiratkan bahwa kompiler dapat menukar operasi, prosesor juga dapat menjalankan instruksi secara paralel, menukar mereka. Tetapi mereka harus melakukan ini agar efek yang terlihat dari eksekusi program Anda sama seperti jika dieksekusi secara langsung secara berurutan.
Kedua, apa yang disebut makna udara tipis yang diambil entah dari mana dilarang dalam bahasa ini. Sayangnya, kami tidak punya waktu untuk menunjukkannya, tetapi ada kasus ketika kompiler benar-benar dapat melakukan konversi sehingga semuanya akan benar dalam program berulir tunggal, dan Anda mungkin memiliki catatan dalam program multi-utas yang tidak Anda lakukan.
Dengan demikian, model memori mengatakan bahwa membaca variabel apa pun akan mengembalikan nilai default, atau beberapa hasil rekaman yang pernah dilakukan oleh perintah lain. Dan tindakan yang tersisa dapat ditafsirkan sebagai berurutan, jika mereka dihubungkan oleh hubungan urutan parsial terjadi sebelumnya. Dan sekarang ini adalah satu-satunya tempat di mana kita membutuhkan matematika. Hubungan parsial, ini karena tidak semua membaca, menulis operasi variabel, mereka terhubung oleh relasi. Ini memiliki sifat refleksifitas, transitivitas, dan antisimetri.

Mari kita bicara lebih detail tentang yang terjadi sebelum itu sendiri. Aturan pertama adalah bahwa ia menautkan semua operasi dalam satu utas. Jika Anda telah menulis di dalam satu utas bahwa X sama dengan satu, Y sama dengan satu; dinyatakan bahwa operasi penulisan dalam X terkait dengan terjadi-sebelum Y. Yaitu, X terjadi-sebelum Y. Dan juga mengikat beberapa tindakan khusus, yang disebut tindakan sinkronisasi. Baca lebih lanjut dalam spesifikasi. Misalnya, ini menulis dan membaca dari variabel volatil, mengunci / membuka kunci pada satu monitor, memasuki blok yang disinkronkan dan keluar dari blok yang disinkronkan. Poin yang sangat penting adalah bahwa semua tindakan sinkronisasi dalam program Anda melihat utas dalam urutan yang persis sama, seolah-olah itu dijalankan satu per satu.
Dan terjadi - sebelum menghubungkan beberapa pasang tindakan ini. Tidak masalah di mana tindakan sinkronisasi utas berlangsung. Penting bahwa mereka lulus, misalnya, lebih dari satu variabel volatil. Spesifikasi mengatakan, katakanlah, menulis ke variabel volatil terjadi sebelum tindakan berikutnya. Ini merujuk persis pada cara kami melakukan tindakan sinkronisasi.
Dan yang paling penting dari semua ini adalah aturan terjadi-sebelum konsistensi, yang hanya menjawab pertanyaan paling penting tentang model memori. Itu bisa diartikan sebagai berikut. Jika ada rantai operasi baca / tulis dalam variabel dan mereka terhubung oleh rantai hubungan sebelum terjadi, maka membaca pasti akan melihat catatan terakhir dalam rantai ini. Jika tidak ada di sana, Anda dapat melihat nilai lain, catatan lain, atau nilai default. Sekarang Anda dapat menghembuskan napas, dengan definisi dasar kami selesai.

Mari kita coba menguji teori dalam praktik? Mari kita ambil contoh dengan pembacaan silang catatan dan tambahkan saja pengubah volatil ke variabel X dan Y. Mari kita coba buktikan hipotesis bahwa kita tidak akan melihat nilai nol-nol lagi. Untuk melakukan ini, cukup gunakan aturan yang saya katakan di atas.
Kami akan mengatur yang terjadi sebelumnya dalam satu utas. Menulis ke X terjadi sebelum membaca dari Y dan di utas kedua. Menulis ke Y terjadi - sebelum membaca dari X.
Dan kemudian kita memiliki empat tindakan sinkronisasi: menulis ke X, menulis ke Y, membaca dari X, membaca dari Y. Mereka dapat muncul dalam beberapa urutan, dan sepasang dapat terjadi dalam dua kasus.

Misalnya, menulis ke X dalam aliran satu terjadi lebih awal daripada membaca dari X dalam aliran dua (terjadi-sebelum terjadi). Seperti yang Anda lihat di sini, hubungan tersebut tidak terkait dengan Y. Hasil pembacaan dari Y hanya dapat kembali kepada kami baik nilai default atau nilai yang dicatat oleh stream kedua. Pembacaan dari X harus selalu melihat unit. Dengan demikian, opsi kami mungkin nol-satu, satu-satu.

Kasus kedua adalah ketika koneksi muncul. Ini adalah hal yang sama - menulis ke Y terjadi sebelum membaca dari Y. Juga tidak ada hubungan antara X. Dengan demikian, hasilnya sama, hanya di sana Anda mendapatkan satu-nol, nol-satu. Secara teoritis, kita dapat membuktikan perilaku program baru kita.

Anda dapat memeriksanya dalam praktik. Ambil dan tambahkan kata kunci yang tidak stabil dalam pengujian kami. Jalankan dan lihat bahwa, di negara kita, nilai ini tidak akan pernah direproduksi. happens-before — . .

, . volatile Z volatile, . , Z; , , , Z. happens-before . , Z , . .
, , — put value. — get value . happens-before , , put value happens-before get value. , happens-before , volatile, . , , — put happens-before get.

, . -, . , , . , . , , . , . , , , , .
-, , jcstress. : , JVM . , .
, . — «The Art of Multiprocessor Programming» . , happens-before, , . . — «Java Concurrency in Practice» . , . , , . . .
, performance- Oracle, Red Hat. , Java- , . JMM.
Anda dapat membaca blog Roman Elizarov . Dia mengajarkan, menurut saya, pemrograman multithreaded ITMO. Dia memiliki blog yang sedikit ditinggalkan, tetapi Anda dapat membaca, mencari ceramah dan pidatonya di YouTube. Secara umum, sangat cocok, saya sarankan. Terima kasih semuanya.