Android 3.0 dan versi platform yang lebih baru telah dioptimalkan untuk mendukung arsitektur multiprosesor. Dokumen ini membahas masalah yang dapat muncul ketika menulis kode multi-thread untuk sistem multiprosesor simetris dalam bahasa pemrograman C, C++, dan Java (selanjutnya, agar lebih singkat, disebut "Java"). Dokumen ini dimaksudkan sebagai pedoman dasar bagi developer aplikasi Android, bukan sebagai bahasan lengkap mengenai subjek tersebut.
Pengantar
SMP adalah singkatan dari “Symmetric Multi-Prosesor”. SMP menggambarkan desain dua inti CPU identik atau lebih berbagi akses ke memori utama. Hingga beberapa tahun yang lalu, semua perangkat Android hanya memiliki prosesor tunggal (UP/Uni-Processor).
Sebagian besar — atau hampir semua — perangkat Android selalu memiliki banyak CPU, tetapi dahulu hanya satu dari sekian CPU tersebut yang digunakan untuk menjalankan aplikasi, sementara sisanya mengelola berbagai hardware perangkat (misalnya, radio). CPU tersebut mungkin memiliki arsitektur yang berbeda-beda, dan program yang berjalan padanya tidak dapat menggunakan memori utama untuk berkomunikasi satu sama lain.
Sebagian besar perangkat Android yang dijual saat ini dibuat berdasarkan desain SMP, yang menjadikan beberapa hal sedikit lebih rumit bagi developer software. Kondisi race dalam program multi-thread mungkin tidak menimbulkan masalah yang kentara pada perangkat dengan prosesor tunggal, tetapi mungkin mengalami kegagalan secara teratur saat dua thread atau lebih berjalan bersamaan pada inti berbeda. Terlebih lagi, kode mungkin menjadi lebih atau kurang rentan terhadap kegagalan saat berjalan pada arsitektur prosesor yang berbeda, atau bahkan pada implementasi yang berbeda dari arsitektur yang sama. Kode yang telah diuji secara menyeluruh pada arsitektur x86 dapat mengalami kerusakan yang parah di ARM. Kode dapat mulai mengalami kegagalan saat dikompilasi ulang dengan compiler yang lebih modern.
Bagian selanjutnya dari dokumen ini akan menjelaskan alasan, dan memberitahukan hal yang perlu dilakukan untuk memastikan bahwa kode Anda berperilaku dengan benar.
Model konsistensi memori: Mengapa SMP sedikit berbeda
Berikut adalah ringkasan singkat dan padat untuk sebuah subjek yang kompleks. Tidak semua hal akan dibahas sepenuhnya, tetapi tidak ada bahasan yang menyesatkan atau salah. Seperti yang akan Anda lihat di bagian selanjutnya, di sini detail biasanya tidak penting.
Lihat Bacaan lebih lanjut di bagian akhir dokumen untuk mendapatkan referensi yang membahas subjek ini secara yang lebih menyeluruh.
Model konsistensi memori, atau biasanya hanya disebut "model memori", menjelaskan jaminan yang diberikan bahasa pemrograman atau arsitektur hardware tentang akses memori. Misalnya, jika Anda menulis sebuah nilai ke alamat A, lalu menulis sebuah nilai ke alamat B, model memori akan menjamin bahwa setiap inti CPU melihat operasi tulis tersebut terjadi sesuai urutan yang ditetapkan.
Model yang paling umum di kalangan programmer adalah konsistensi berurutan (sequential consistency), yang dideskripsikan sebagai berikut (Adve & Gharachorloo):
- Semua operasi memori tampak dieksekusi satu per satu
- Semua operasi pada thread tunggal tampak dieksekusi sesuai urutan yang dijelaskan oleh program prosesor.
Mari kita asumsikan untuk sementara bahwa kita memiliki compiler atau interpreter sangat sederhana yang tidak akan memberikan hal di luar perkiraan: Compiler atau interpreter ini menerjemahkan tugas dalam kode sumber untuk memuat dan menyimpan instruksi persis sesuai urutan, satu instruksi per akses. Untuk sederhananya, kita juga akan mengasumsikan bahwa setiap thread dieksekusi pada prosesornya sendiri.
Jika Anda melihat sepenggal kode dan mengetahui bahwa kode tersebut menjalankan operasi baca dan tulis dari memori, pada arsitektur CPU yang konsisten berurutan, Anda tahu bahwa kode tersebut akan menjalankan operasi baca dan tulis sesuai urutan yang diperkirakan. Mungkin saja CPU mengubah urutan instruksi serta menunda operasi baca dan tulis, tetapi kode yang berjalan pada perangkat tersebut tidak akan mungkin mengetahui bahwa CPU melakukan hal lain selain mengeksekusi instruksi tersebut apa adanya. (Kita akan mengabaikan I/O driver perangkat yang dipetakan memori.)
Untuk memberikan gambaran poin-poin di atas, mari kita pertimbangkan cuplikan singkat kode yang biasanya disebut pengujian litmus.
Berikut ini contoh sederhana, dengan kode yang berjalan pada dua thread:
Thread 1 | Thread 2 |
---|---|
A = 3 |
reg0 = B |
Dalam contoh ini dan semua contoh litmus selanjutnya, lokasi memori ditunjukkan oleh huruf kapital (A, B, C) dan register CPU dimulai dengan "reg". Semua memori adalah nol pada awalnya. Instruksi akan dieksekusi dari atas ke bawah. Di sini, thread 1 menyimpan nilai 3 di lokasi A, lalu nilai 5 di lokasi B. Thread 2 memuat nilai dari lokasi B ke reg0, lalu dari lokasi A ke reg1. (Perhatikan bahwa kita menulis dalam satu urutan, dan membaca dalam satu urutan lainnya.)
Thread 1 dan thread 2 diasumsikan dieksekusi pada inti CPU yang berbeda. Sebaiknya Anda selalu menggunakan asumsi ini ketika memikirkan kode multi-thread.
Konsistensi berurutan menjamin bahwa, setelah kedua thread selesai dieksekusi, register akan berada dalam salah satu status berikut:
Register | Status |
---|---|
reg0=5, reg1=3 | mungkin (thread 1 berjalan lebih dahulu) |
reg0=0, reg1=0 | mungkin (thread 2 berjalan lebih dahulu) |
reg0=0, reg1=3 | mungkin (dieksekusi bersamaan) |
reg0=5, reg1=0 | tidak pernah |
Untuk mendapatkan situasi B=5 sebelum penyimpanan ke A, salah satu dari operasi baca atau tulis harus berjalan tidak sesuai urutan. Pada mesin yang konsisten berurutan, hal seperti itu tidak akan terjadi.
Perangkat dengan prosesor tunggal, termasuk x86 dan ARM, biasanya menggunakan model konsisten berurutan. Thread tampak dieksekusi secara berselang-seling, sementara kernel OS beralih dari satu thread ke thread berikutnya. Sebagian besar sistem SMP, termasuk x86 dan ARM, tidak menggunakan model konsisten berurutan. Misalnya, wajar bagi hardware untuk melakukan buffering penyimpanan saat dalam perjalanan ke memori sehingga penyimpanan tidak langsung mencapai memori dan menjadi terlihat oleh inti lainnya.
Detailnya sangat berbeda-beda. Sebagai contoh, meskipun tidak konsisten berurutan, x86 tetap menjamin bahwa reg0=5 dan reg1=0 tidak mungkin terjadi. Penyimpanan di-buffer, tetapi urutannya dipertahankan. Di sisi lain, ARM tidak seperti itu. Urutan penyimpanan yang di-buffer tidak dipertahankan, dan penyimpanan mungkin tidak mencapai semua inti lainnya secara bersamaan. Perbedaan-perbedaan ini penting bagi assembly programmer. Namun, seperti yang akan kita lihat di bawah, programmer C, C++, atau Java dapat dan seharusnya memprogram sedemikian rupa sehingga perbedaan arsitektur semacam itu dapat disembunyikan.
Sejauh ini, kita berasumsi secara tidak realistis bahwa hanya hardware-lah yang mengubah urutan instruksi. Pada kenyataannya, compiler juga mengubah urutan instruksi untuk meningkatkan performa. Dalam contoh kita, compiler mungkin memutuskan bahwa kode berikutnya pada Thread 2 memerlukan nilai reg1 sebelum memerlukan reg0, sehingga harus memuat reg1 terlebih dahulu. Atau, beberapa kode sebelumnya mungkin telah memuat A, dan compiler memutuskan untuk menggunakan kembali nilai tersebut, bukannya memuat A lagi. Apa pun kasusnya, urutan pemuatan ke reg0 dan reg1 mungkin berubah.
Pengubahan urutan akses ke lokasi memori yang berbeda, baik pada hardware maupun compiler, diperbolehkan karena hal tersebut tidak memengaruhi eksekusi thread tunggal, dan dapat meningkatkan performa secara signifikan. Seperti yang akan kita lihat, dengan sedikit kecermatan, kita juga dapat mencegah agar pengubahan urutan tidak memengaruhi hasil program multi-thread.
Karena compiler juga dapat mengubah urutan akses memori, masalah ini sebenarnya bukan hal baru bagi SMP. Bahkan pada perangkat dengan prosesor tunggal pun, compiler dapat mengubah urutan pemuatan ke reg0 dan reg1 dalam contoh kita, dan Thread 1 dapat dijadwalkan di antara instruksi yang urutannya telah diubah. Namun jika compiler kita ternyata tidak mengubah urutan, kita mungkin tidak akan pernah mengalami masalah ini. Pada sebagian besar SMP ARM, bahkan tanpa pengubahan urutan oleh compiler pun, pengubahan urutan mungkin akan terjadi, biasanya setelah sejumlah besar eksekusi yang berhasil. Kecuali Anda melakukan pemrograman dalam bahasa assembly, SMP biasanya hanya meningkatkan kemungkinan Anda menemukan masalah yang memang sudah ada selama ini.
Pemrograman bebas data race
Untungnya, ada cara mudah untuk menghindari memikirkan semua detail ini. Dengan mengikuti beberapa aturan sederhana, Anda dapat melupakan semua pembahasan sebelumnya kecuali bagian "konsistensi berurutan". Sayangnya, masalah lain dapat muncul jika Anda tidak sengaja melanggar aturan tersebut.
Bahasa pemrograman modern mendorong konsep yang disebut gaya pemrograman "bebas data race". Selama Anda memastikan untuk tidak menimbulkan "data race", dan menghindari beberapa konstruksi yang akan memerintah compiler untuk mengubah urutan, compiler dan hardware pasti akan memberikan hasil yang konsisten berurutan. Ini tidak benar-benar berarti bahwa compiler dan hardware menghindari pengubahan urutan akses memori. Namun ini berarti bahwa, jika mengikuti aturan, Anda tidak akan tahu bahwa urutan akses memori sedang diubah. Ini sama seperti memberi tahu Anda bahwa sosis itu makanan lezat dan menggugah selera, selama Anda yakin untuk tidak mengunjungi pabrik sosis. Data race adalah apa yang akan mengungkap kenyataan buruk tentang pengubahan urutan memori.
Apa yang dimaksud dengan "data race"?
Data race terjadi ketika minimal dua thread mengakses data biasa yang sama secara bersamaan, dan minimal salah satu thread tersebut memodifikasi data tersebut. Yang kami maksud "data biasa" ini adalah sesuatu yang secara khusus bukan merupakan objek sinkronisasi yang dimaksudkan untuk komunikasi thread. Mutex, variabel kondisi, volatile Java, atau objek C++ atomic bukanlah data biasa, dan aksesnya diperbolehkan untuk race. Bahkan, jenis data tersebut digunakan untuk mencegah data race pada objek lain.
Untuk menentukan apakah dua thread mengakses lokasi memori yang sama secara bersamaan, kita dapat mengabaikan pembahasan tentang pengubahan urutan memori di atas, dan mengasumsikan konsistensi berurutan. Program berikut tidak memiliki data race jika A
dan B
adalah variabel boolean biasa yang pada awalnya bernilai false:
Thread 1 | Thread 2 |
---|---|
if (A) B = true |
if (B) A = true |
Karena urutan operasi tidak diubah, kedua kondisi akan dievaluasi ke false, dan tidak ada variabel yang diupdate. Dengan demikian, tidak mungkin terjadi data race. Anda tidak perlu mengkhawatirkan apa yang mungkin terjadi jika urutan pemuatan dari A
dan penyimpanan ke B
pada Thread 1 diubah. Compiler tidak diizinkan mengubah urutan Thread 1 dengan menulisnya ulang sebagai "B = true; if (!A) B = false
". Itu sama seperti membuat sosis di tengah kota pada siang bolong.
Data race secara resmi ditentukan pada jenis built-in dasar seperti integer dan referensi atau pointer. Menetapkan sebuah int
dan membacanya secara bersamaan pada thread lain jelas merupakan data race. Namun, baik library standar C++ maupun library Java Collections ditulis untuk memungkinkan Anda memikirkan terjadinya data race di tingkat library. Kedua library ini tidak akan menimbulkan data race kecuali ada akses serentak ke container yang sama, dan setidaknya salah satu akses tersebut mengubah container. Mengupdate sebuah set<T>
pada satu thread dan membacanya secara bersamaan pada thread lain membuat library dapat menimbulkan data race, dan dengan demikian dapat dianggap secara informal sebagai "data race tingkat library".
Sebaliknya, mengupdate sebuah set<T>
pada satu thread, sambil membaca kode yang berbeda pada thread lain, tidak akan mengakibatkan data race karena library memastikan untuk tidak menimbulkan data race (tingkat rendah) dalam kasus ini.
Biasanya, akses serentak ke kolom yang berbeda dalam sebuah struktur data tidak akan menimbulkan data race. Namun, ada satu pengecualian penting untuk aturan ini: Urutan kolom bit yang saling berdekatan (contiguous) di C atau C++ diperlakukan sebagai satu "lokasi memori". Untuk mengetahui keberadaan data race, mengakses kolom bit dalam rangkaian semacam ini akan diperlakukan seolah-olah mengakses semua kolom. Ini merefleksikan ketidakmampuan hardware biasa untuk mengupdate bit tertentu tanpa membaca dan menulis ulang bit yang berdekatan. Programmer Java tidak mengalami masalah semacam itu.
Menghindari data race
Bahasa pemrograman modern menyediakan sejumlah mekanisme sinkronisasi untuk menghindari data race. Fitur yang paling dasar adalah:
- Kunci atau Mutex
- Mutex (
std::mutex
ataupthread_mutex_t
pada C++11), atau bloksynchronized
pada Java dapat digunakan untuk memastikan bahwa bagian kode tertentu tidak berjalan serentak dengan bagian kode lain yang mengakses data yang sama. Kita menyebut fitur ini dan fasilitas serupa lainnya secara umum dengan "kunci". Mendapatkan kunci tertentu secara konsisten sebelum mengakses sebuah struktur data bersama, kemudian melepaskan kunci tersebut, dapat mencegah data race saat mengakses struktur data tersebut. Fitur ini juga memastikan bahwa update dan akses bersifat atomic; artinya, tidak ada update lain pada struktur data yang dapat berjalan di tengah-tengah. Sejauh ini, fitur ini merupakan fitur yang paling umum untuk mencegah data race. Penggunaan bloksynchronized
pada Java ataulock_guard
atauunique_lock
pada C++ memastikan bahwa kunci dilepas dengan tepat jika terjadi pengecualian. - Variabel volatile/atomic
- Java menyediakan kolom
volatile
yang mendukung akses serentak tanpa menimbulkan data race. Sejak tahun 2011, C dan C++ mendukung kolom dan variabelatomic
dengan semantik serupa. Fitur ini biasanya lebih sulit digunakan daripada kunci karena hanya memastikan bahwa akses tertentu ke satu variabel bersifat atomic. (Pada C++, fitur ini biasanya meluas ke operasi baca-modifikasi-tulis sederhana, seperti kenaikan (increment). Java memerlukan panggilan metode khusus untuk itu.) Tidak seperti kunci, variabelvolatile
atauatomic
tidak dapat digunakan langsung untuk mencegah thread lain mengganggu urutan kode yang lebih panjang.
Perlu diingat bahwa volatile
memiliki arti yang sangat berbeda pada C++ dan Java. Pada C++, volatile
tidak mencegah data race meskipun kode yang lebih lama sering menggunakannya untuk mengatasi tidak adanya objek atomic
. Cara ini tidak lagi direkomendasikan; pada C++, gunakan atomic<T>
untuk variabel yang dapat diakses serentak oleh banyak thread. C++ volatile
dimaksudkan untuk register perangkat dan sejenisnya.
Variabel C/C++ atomic
atau variabel Java volatile
dapat digunakan untuk mencegah data race pada variabel lain. Jika flag
dideklarasikan memiliki jenis atomic<bool>
atau atomic_bool
(C/C++) atau volatile boolean
(Java), dan pada awalnya bernilai false, cuplikan berikut bebas dari data race:
Thread 1 | Thread 2 |
---|---|
A = ...
|
while (!flag) {}
|
Karena Thread 2 menunggu flag
ditetapkan, akses ke A
pada Thread 2 harus terjadi setelah, dan tidak bersamaan dengan, penetapan ke A
pada Thread 1. Dengan demikian, data race tidak akan terjadi pada A
. Race pada flag
tidak dihitung sebagai data race, karena akses volatile/atomic bukan merupakan "akses memori biasa".
Implementasi ini diperlukan untuk mencegah atau menyembunyikan pengubahan urutan memori secara memadai agar kode seperti pengujian litmus di atas berperilaku sesuai harapan. Hal ini biasanya menjadikan akses memori volatile/atomic jauh lebih berat daripada akses biasa.
Meskipun contoh di atas bebas data race, kunci bersama Object.wait()
pada Java, atau variabel kondisi pada C/C++, biasanya memberikan solusi yang lebih baik karena tidak membuat menunggu dalam loop yang menguras daya baterai.
Ketika pengubahan urutan memori menjadi terlihat
Pemrograman bebas data race biasanya menghindarkan kita dari keharusan untuk menangani masalah pengubahan urutan akses memori secara eksplisit. Namun, ada beberapa kasus ketika pengubahan urutan menjadi terlihat:- Jika program Anda memiliki bug yang mengakibatkan data race yang tidak disengaja, transformasi hardware dan compiler dapat menjadi terlihat, dan program Anda mungkin berperilaku di luar perkiraan. Misalnya, jika kita lupa mendeklarasikan
flag
volatile pada contoh di atas, Thread 2 dapat melihat lokasi memoriA
yang tidak diinisialisasi. Atau, compiler dapat memutuskan bahwa flag tidak akan berubah selama loop Thread 2 dan mengubah program menjadi:Thread 1 Thread 2 A = ...
flag = truereg0 = flag; sementara (!reg0) {}
... = Aflag
ditetapkan ke true. - C++ menyediakan fasilitas untuk menyesuaikan konsistensi berurutan secara eksplisit meskipun tidak terjadi race. Operasi atomic dapat menerima argumen
memory_order_
... eksplisit. Demikian pula, paketjava.util.concurrent.atomic
menyediakan kumpulan fasilitas serupa yang lebih terbatas, terutamalazySet()
. Dan programmer Java sesekali menggunakan data race yang disengaja untuk mendapatkan efek serupa. Semua ini memberikan peningkatan performa dengan biaya yang mahal berupa kerumitan pemrograman. Kita membahasnya secara singkat di bawah. - Beberapa kode C dan C++ ditulis dengan gaya lama, yang tidak sepenuhnya konsisten dengan standar bahasa terkini, di mana variabel
volatile
digunakan sebagai penggantiatomic
, dan pengurutan memori dilarang secara eksplisit melalui penyisipan elemen yang disebut fence atau barrier. Cara ini memerlukan pertimbangan eksplisit terkait pengubahan urutan akses dan pemahaman tentang model memori hardware. Gaya coding semacam ini masih digunakan di kernel Linux. Gaya tersebut tidak dapat digunakan dalam aplikasi Android baru, dan juga tidak dibahas lebih lanjut di sini.
Praktik
Men-debug masalah konsistensi memori dapat menjadi sangat sulit. Jika deklarasi kunci, atomic
, atau volatile
yang hilang menyebabkan beberapa kode membaca data yang usang, Anda mungkin tidak dapat menemukan alasannya dengan memeriksa dump memori dengan debugger. Pada saat Anda dapat membuat kueri debugger, inti CPU mungkin telah penuh dengan akses, dan konten memori serta register CPU akan terlihat dalam status "tidak mungkin".
Yang tidak boleh dilakukan pada C
Di bagian ini, kami menyajikan beberapa contoh kode yang salah beserta cara sederhana untuk memperbaikinya. Sebelum melakukannya, kita perlu membahas penggunaan sebuah fitur bahasa dasar.
C/C++ dan "volatile"
Deklarasi volatile
pada C++ dan C adalah fitur dengan kegunaan yang sangat khusus.
Fitur ini mencegah compiler mengubah urutan atau menghapus akses yang volatile. Fitur ini dapat berguna untuk kode yang mengakses register perangkat hardware, memori yang dipetakan ke lebih dari satu lokasi, atau yang berhubungan dengan setjmp
. Namun, tidak seperti volatile
pada Java, C dan C++ volatile
tidak didesain untuk komunikasi thread.
Pada C dan C++, urutan akses ke data volatile
dapat diubah dengan akses ke data non-volatile, dan tidak ada jaminan akses bersifat atomic. Dengan demikian, volatile
tidak dapat digunakan untuk berbagi data antar-thread dalam kode portabel, bahkan pada prosesor tunggal. C volatile
biasanya tidak mencegah pengubahan urutan akses oleh hardware sehingga dengan sendirinya deklarasi tersebut kurang begitu berguna dalam lingkungan SMP multi-thread. Inilah alasan C11 dan C++11 mendukung objek atomic
. Anda disarankan untuk menggunakannya.
Banyak kode C dan C++ versi lama yang masih menyalahgunakan volatile
untuk komunikasi thread. Cara ini terkadang berfungsi dengan baik untuk data yang sesuai dengan register mesin, asalkan digunakan dengan fence eksplisit atau dalam kasus ketika pengurutan memori bukan hal penting. Namun, cara ini tidak dijamin akan berfungsi dengan baik pada compiler mendatang.
Contoh
Pada sebagian besar kasus, akan lebih baik jika Anda menggunakan kunci (seperti pthread_mutex_t
atau C++11 std::mutex
) daripada operasi atomic, tetapi kita akan menerapkan metode yang disebut terakhir ini untuk memberikan gambaran penggunaannya dalam situasi sebenarnya.
MyThing* gGlobalThing = NULL; // Wrong! See below. void initGlobalThing() // runs in Thread 1 { MyStruct* thing = malloc(sizeof(*thing)); memset(thing, 0, sizeof(*thing)); thing->x = 5; thing->y = 10; /* initialization complete, publish */ gGlobalThing = thing; } void useGlobalThing() // runs in Thread 2 { if (gGlobalThing != NULL) { int i = gGlobalThing->x; // could be 5, 0, or uninitialized data ... } }
Intinya di sini adalah kita mengalokasikan struktur, menginisialisasi kolomnya, dan pada akhirnya "memublikasikannya" dengan menyimpan struktur tersebut dalam variabel global. Pada tahap itu, semua thread lain akan dapat melihatnya, tetapi itu tidak masalah karena struktur sudah diinisialisasi sepenuhnya, bukan?
Masalahnya adalah penyimpanan ke gGlobalThing
dapat terjadi sebelum kolom diinisialisasi, yang biasanya terjadi karena compiler atau prosesor mengubah urutan penyimpanan ke gGlobalThing
dan thing->x
. Thread lain yang membaca dari thing->x
dapat menemukan 5, 0, atau bahkan data yang tidak diinisialisasi.
Masalah intinya di sini adalah data race di gGlobalThing
.
Jika Thread 1 memanggil initGlobalThing()
sementara Thread 2 memanggil useGlobalThing()
, gGlobalThing
dapat dibaca saat sedang ditulis.
Kondisi ini dapat diperbaiki dengan mendeklarasikan gGlobalThing
sebagai atomic. Pada C++11:
atomic<MyThing*> gGlobalThing(NULL);
Cara ini memastikan bahwa operasi tulis akan terlihat oleh thread lain dalam urutan yang tepat. Cara ini juga menjamin tidak munculnya beberapa mode kegagalan lain yang diizinkan, tetapi kecil kemungkinannya untuk terjadi pada hardware Android sebenarnya. Misalnya, cara ini memastikan bahwa kita tidak dapat melihat pointer gGlobalThing
yang baru ditulis sebagian.
Yang tidak boleh dilakukan pada Java
Kita belum membahas beberapa fitur bahasa pemrograman Java yang relevan, jadi kita akan melihatnya secara singkat terlebih dahulu.
Secara teknis, Java tidak memerlukan kode agar bebas data race. Dan ada sejumlah kecil kode Java yang ditulis dengan sangat hati-hati yang berfungsi dengan baik meskipun terdapat data race. Namun, menulis kode semacam itu sangat sulit, dan kita hanya membahasnya secara singkat di bawah. Parahnya lagi, pakar yang menentukan arti kode tersebut tidak lagi percaya bahwa spesifikasi itu benar. (Spesifikasi tersebut cocok untuk kode bebas data race).
Untuk saat ini kita akan mematuhi model bebas data race, model yang diberikan jaminan yang sama dengan C dan C++ oleh Java. Sekali lagi, Java menyediakan beberapa primitive yang secara eksplisit menyesuaikan konsistensi urutan, terutama panggilan lazySet()
dan weakCompareAndSet()
di java.util.concurrent.atomic
.
Seperti pada C dan C++, kita akan mengabaikannya untuk saat ini.
Kata kunci "disinkronkan" dan "volatile" pada Java
Kata kunci "disinkronkan" memberikan mekanisme penguncian bawaan Java. Setiap objek memiliki "monitor" terkait yang dapat digunakan untuk memberikan akses eksklusif satu sama lain. Jika dua thread mencoba "menyinkronkan" objek yang sama, salah satunya akan menunggu sampai thread yang lain selesai.
Seperti disebutkan di atas, Java volatile T
sama dengan C++11 atomic<T>
. Akses serentak ke kolom volatile
diizinkan, dan tidak mengakibatkan data race.
Dengan mengabaikan lazySet()
dll. serta data race, fungsi Java VM adalah untuk memastikan bahwa hasil tetap terlihat konsisten berurutan.
Secara khusus, jika thread 1 menulis ke kolom volatile
, dan setelahnya thread 2 membaca dari kolom yang sama serta melihat nilai yang baru ditulis, thread 2 juga dijamin melihat semua operasi tulis yang dibuat sebelumnya oleh thread 1. Dari segi efek memori, menulis ke volatile mirip dengan rilis monitor, sedangkan membaca dari volatile mirip dengan pemerolehan monitor.
Ada satu perbedaan penting dari C++ atomic
: Jika kita menulis volatile int x;
di Java, x++
akan sama dengan x = x + 1
; volatile melakukan pemuatan atomic, meningkatkan hasil, lalu melakukan penyimpanan atomic. Tidak seperti C++, kenaikan secara keseluruhan tidak bersifat atomic.
Operasi kenaikan atomic disediakan oleh java.util.concurrent.atomic
.
Contoh
Berikut adalah implementasi sederhana yang salah dari penghitung monotonik: (Java theory and practice: Managing volatility).
class Counter { private int mValue; public int get() { return mValue; } public void incr() { mValue++; } }
Anggaplah get()
dan incr()
dipanggil dari beberapa thread, dan kita ingin memastikan bahwa setiap thread melihat jumlah saat ini ketika get()
dipanggil. Masalah yang paling mencolok adalah bahwa mValue++
sebenarnya mencakup tiga operasi:
reg = mValue
reg = reg + 1
mValue = reg
Jika dua thread dieksekusi di incr()
secara bersamaan, salah satu update dapat hilang. Untuk membuat kenaikan atomic, kita perlu mendeklarasikan incr()
sebagai “disinkronkan”.
Namun itu tetap tidak berhasil, terutama di SMP. Data race tetap terjadi, karena get()
dapat mengakses mValue
secara bersamaan dengan incr()
. Sesuai aturan Java, panggilan get()
dapat terlihat berubah urutan dalam kaitannya dengan kode lain. Misalnya, jika kita membaca dua penghitung secara berurutan, hasilnya mungkin terlihat tidak konsisten karena panggilan get()
diubah urutannya, entah oleh hardware atau compiler. Kita dapat memperbaiki masalah tersebut dengan mendeklarasikan get()
sebagai disinkronkan. Dengan perubahan ini, kode tersebut menjadi benar.
Sayangnya, kita telah memasukkan kemungkinan perebutan kunci, yang dapat menghambat performa. Bukan lagi mendeklarasikan get()
sebagai disinkronkan, kita sekarang dapat mendeklarasikan mValue
sebagai "volatile". (Catatan: incr()
tetap harus menggunakan synchronize
karena mValue++
bukan operasi atomic tunggal).
Cara ini juga menghindarkan dari semua data race, sehingga konsistensi urutan dapat dipertahankan.
incr()
akan menjadi sedikit lebih lambat, karena kode tersebut menangani overhead keluar/masuk monitor, serta overhead yang terkait dengan penyimpanan volatile, tetapi get()
akan menjadi lebih cepat; jadi meskipun tidak terjadi perebutan, ini merupakan sebuah keuntungan jika jumlah operasi baca jauh melebihi operasi tulis. (Lihat juga AtomicInteger
untuk mengetahui cara menghapus sepenuhnya blok yang disinkronkan.)
Berikut adalah contoh lain, yang formatnya mirip dengan contoh C sebelumnya:
class MyGoodies { public int x, y; } class MyClass { static MyGoodies sGoodies; void initGoodies() { // runs in thread 1 MyGoodies goods = new MyGoodies(); goods.x = 5; goods.y = 10; sGoodies = goods; } void useGoodies() { // runs in thread 2 if (sGoodies != null) { int i = sGoodies.x; // could be 5 or 0 .... } } }
Contoh ini memiliki masalah yang sama dengan kode C, yaitu adanya data race pada sGoodies
. Dengan demikian, penetapan sGoodies = goods
dapat diamati sebelum inisialisasi kolom pada goods
. Jika Anda mendeklarasikan sGoodies
sebagai kata kunci volatile
, konsistensi berurutan akan dipulihkan, dan semuanya akan bekerja seperti yang diharapkan.
Perhatikan bahwa hanya referensi sGoodies
itu sendiri yang volatile. Akses ke kolom di dalamnya tidak volatile. Saat sGoodies
bersifat volatile
, dan pengurutan memori telah dipertahankan dengan benar, kolom-kolom tersebut tidak dapat diakses serentak. Pernyataan z =
sGoodies.x
akan melakukan pemuatan volatile MyClass.sGoodies
yang diikuti dengan pemuatan sGoodies.x
yang tidak volatile. Jika Anda membuat referensi lokal MyGoodies localGoods = sGoodies
, z =
localGoods.x
berikutnya tidak akan melakukan pemuatan volatile.
Idiom yang lebih umum dipakai pada pemrograman Java adalah “double-checked locking” yang terkenal:
class MyClass { private Helper helper = null; public Helper getHelper() { if (helper == null) { synchronized (this) { if (helper == null) { helper = new Helper(); } } } return helper; } }
Idenya adalah kita ingin mendapatkan satu instance objek Helper
yang terkait dengan instance MyClass
. Kita hanya boleh membuatnya satu kali, jadi kita membuat dan mengembalikannya melalui fungsi getHelper()
khusus. Untuk menghindari race di mana dua thread membuat instance yang sama, kita perlu menyinkronkan pembuatan objek. Namun, kita tidak ingin mengorbankan blok yang “disinkronkan” demi overhead pada setiap panggilan, jadi bagian itu hanya dilakukan jika helper
bernilai null.
Instance ini mengandung data race pada kolom helper
. Instance ini dapat ditetapkan serentak dengan helper == null
di thread lain.
Untuk melihat bagaimana ini bisa gagal, pertimbangkan kode serupa yang sedikit ditulis ulang, seolah-olah dikompilasi ke dalam bahasa yang mirip C (Saya telah menambahkan beberapa kolom integer untuk menunjukkan aktivitas konstruktor Helper’s
):
if (helper == null) { synchronized() { if (helper == null) { newHelper = malloc(sizeof(Helper)); newHelper->x = 5; newHelper->y = 10; helper = newHelper; } } return helper; }
Tidak ada yang dapat mencegah hardware atau compiler untuk mengubah urutan penyimpanan ke helper
dengan penyimpanan ke kolom x
/y
. Thread lain dapat menemukan helper
non-null, tetapi kolomnya belum ditetapkan dan belum siap digunakan.
Untuk mengetahui detail selengkapnya dan mode kegagalan lainnya, lihat link “‘Double Checked Locking is Broken’ Declaration” di bagian lampiran, atau Item 71 (“Use lazy initialization judiciously”) dalam Effective Java, 2nd Edition karya Josh Bloch.
Ada dua cara untuk memperbaiki masalah ini:
- Lakukan implementasi sederhana dan hapus pemeriksaan luar. Cara ini memastikan bahwa kita tidak perlu memeriksa nilai
helper
di luar blok yang disinkronkan. - Deklarasikan volatile
helper
. Dengan satu perubahan kecil ini, kode pada Contoh J-3 akan berfungsi dengan benar pada Java 1.5 dan yang lebih baru. (Anda perlu waktu beberapa saat untuk meyakinkan diri sendiri bahwa itu benar.)
Berikut ilustrasi lain dari perilaku volatile
:
class MyClass { int data1, data2; volatile int vol1, vol2; void setValues() { // runs in Thread 1 data1 = 1; vol1 = 2; data2 = 3; } void useValues() { // runs in Thread 2 if (vol1 == 2) { int l1 = data1; // okay int l2 = data2; // wrong } } }
Dengan melihat useValues()
, jika Thread 2 belum melihat update pada vol1
, thread tersebut tidak dapat mengetahui apakah data1
atau data2
telah ditetapkan atau belum. Setelah melihat update pada vol1
, Thread 2 akan mengetahui bahwa data1
dapat diakses dengan aman dan dibaca dengan tepat tanpa menyebabkan data race. Namun, Thread 2 tidak dapat membuat asumsi tentang data2
karena penyimpanan tersebut dijalankan setelah penyimpanan volatile.
Perhatikan bahwa volatile
tidak dapat digunakan untuk mencegah pengubahan urutan akses memori lain yang melakukan race satu sama lain. Volatile tidak dijamin akan menghasilkan instruksi memory fence mesin. Volatile dapat digunakan untuk mencegah data race dengan mengeksekusi kode hanya ketika thread lain telah memenuhi kondisi tertentu.
Yang harus dilakukan
Pada C/C++, utamakan class sinkronisasi C++11, seperti std::mutex
. Jika tidak, gunakan operasi pthread
yang sesuai.
Operasi ini mencakup memory fence tepat, yang menyediakan perilaku yang benar (konsisten berurutan, kecuali ditentukan lain) dan efisien pada semua versi platform Android. Pastikan untuk menggunakannya dengan benar. Misalnya, ingat bahwa operasi tunggu variabel kondisi dapat muncul secara palsu tanpa diberi sinyal, dan karenanya harus muncul dalam loop.
Sebaiknya hindari penggunaan fungsi atomic secara langsung, kecuali struktur data yang Anda implementasikan sangat sederhana, misalnya penghitung. Penguncian dan pembukaan kunci pthread mutex masing-masing memerlukan satu operasi atomic, dan sering kali lebih rendah risiko daripada satu peristiwa cache yang tidak ditemukan, jika tidak terjadi perebutan kunci, sehingga Anda tidak akan menghemat banyak dengan mengganti panggilan mutex dengan operasi atomic. Desain bebas kunci untuk struktur data non-trivial memerlukan kecermatan yang jauh lebih tinggi untuk memastikan operasi level tinggi pada struktur data terlihat sebagai operasi atomic (secara keseluruhan, bukan hanya bagian yang secara eksplisit memang atomic).
Jika Anda menggunakan operasi atomic, menyesuaikan pengurutan dengan memory_order
... atau lazySet()
dapat memberikan keuntungan performa, tetapi memerlukan pemahaman yang lebih dalam daripada yang telah kita bahas sejauh ini.
Sebagian besar kode yang ada yang menggunakan operasi atomic ternyata berisi bug setelah diterapkan. Hindari hal tersebut sebisa mungkin.
Jika kasus penggunaan Anda tidak cocok dengan salah satu kasus yang dijelaskan di bagian selanjutnya, pastikan Anda menguasai bidang ini atau telah berkonsultasi dengan pakarnya.
Hindari penggunaan volatile
untuk komunikasi thread pada C/C++.
Pada Java, masalah operasi serentak sering kali dapat diselesaikan menggunakan class utility yang sesuai dari paket java.util.concurrent
. Kode ini ditulis dengan baik dan telah teruji di SMP.
Mungkin hal paling aman yang dapat Anda lakukan adalah menjadikan objek Anda immutable (tidak dapat diubah). Objek dari class seperti String dan Integer Java menampung data yang tidak dapat diubah begitu objek dibuat sehingga menghindari kemungkinan terjadinya data race pada objek tersebut. Buku Effective Java, 2nd Edition menyediakan instruksi khusus dalam “Item 15: Minimize Mutability”. Perhatikan dengan cermat pentingnya mendeklarasikan kolom Java sebagai “final" (Bloch).
Meskipun objek sifatnya immutable (tidak dapat diubah), ingat bahwa mengomunikasikannya ke thread lain tanpa melakukan sinkronisasi apa pun dapat menimbulkan data race. Kondisi ini terkadang dapat diterima pada Java (lihat di bawah), tetapi memerlukan kecermatan yang lebih tinggi, dan cenderung menghasilkan kode yang rapuh. Jika tidak benar-benar penting bagi performa, tambahkan deklarasi volatile
. Pada C++, mengomunikasikan pointer atau referensi ke objek yang tidak dapat diubah tanpa sinkronisasi yang tepat, sama seperti data race lainnya, merupakan bug.
Dalam kasus ini, kemungkinan akan terjadi error intermiten karena, misalnya, thread penerima mungkin melihat pointer tabel metode yang belum diinisialisasi akibat pengubahan urutan penyimpanan.
Jika tidak ada class library yang tersedia, atau class immutable yang sesuai, pernyataan Java synchronized
atau C++ lock_guard
/unique_lock
sebaiknya digunakan untuk melindungi akses ke kolom yang dapat diakses oleh lebih dari satu thread. Jika mutex tidak berfungsi pada kasus Anda, sebaiknya deklarasikan kolom bersama volatile
atau atomic
, tetapi Anda harus sangat berhati-hati untuk memahami interaksi antar-thread. Deklarasi ini tidak akan menghindarkan Anda dari kesalahan pemrograman serentak yang umum, tetapi akan membantu menghindarkan Anda dari kegagalan misterius terkait pengoptimalan compiler dan masalah SMP.
Sebaiknya hindari "memublikasikan" referensi ke sebuah objek, yang artinya membuatnya tersedia untuk thread lain, dalam konstruktor objek tersebut. Langkah ini tidak begitu penting pada C++ atau jika Anda mematuhi saran "tidak ada data race" kami pada lingkungan Java. Namun saran tersebut bagus, dan menjadi sangat penting jika kode Java Anda dijalankan dalam konteks lain di mana model keamanan Java sangat diutamakan, dan kode yang tidak tepercaya dapat menyebabkan data race dengan mengakses referensi objek yang "bocor". Cara ini juga sangat penting jika Anda memilih untuk mengabaikan peringatan kami dan menggunakan beberapa teknik di bagian selanjutnya. Lihat (Safe Construction Techniques in Java) untuk detail selengkapnya.
Sedikit lebih lanjut tentang urutan memori yang lemah
C++11 dan yang lebih baru menyediakan mekanisme eksplisit untuk menyesuaikan jaminan konsistensi berurutan untuk program bebas data race. Argumen eksplisit memory_order_relaxed
, memory_order_acquire
(khusus pemuatan), dan memory_order_release
(khusus penyimpanan) untuk operasi atomic masing-masing memberikan jaminan yang lebih lemah daripada memory_order_seq_cst
default yang biasanya implisit. memory_order_acq_rel
menyediakan jaminan memory_order_acquire
dan memory_order_release
untuk operasi baca-modifikasi-tulis atomic. memory_order_consume
belum cukup ditetapkan atau diimplementasikan agar menjadi berguna, dan sebaiknya diabaikan untuk saat ini.
Metode lazySet
pada Java.util.concurrent.atomic
mirip dengan penyimpanan C++ memory_order_release
. Variabel umum Java terkadang digunakan sebagai pengganti akses memory_order_relaxed
, meskipun sebenarnya lebih lemah lagi. Tidak seperti C++, tidak ada mekanisme nyata untuk akses tidak berurutan ke variabel yang dideklarasikan sebagai volatile
.
Secara umum, sebaiknya Anda menghindari hal tersebut kecuali ada alasan performa yang kuat untuk menggunakannya. Pada arsitektur mesin dengan urutan lemah seperti ARM, penggunaannya akan mengamankan urutan sejumlah siklus mesin untuk setiap operasi atomic. Pada arsitektur x86, keuntungan performa ini terbatas pada penyimpanan, dan cenderung kurang terlihat. Sedikit kontra-intuitif, keuntungan tersebut dapat berkurang pada jumlah inti yang lebih besar karena sistem memori menjadi faktor pembatas.
Semantik lengkap untuk operasi atomic dengan urutan lemah sangatlah rumit. Secara umum, operasi atomic ini memerlukan pemahaman yang tepat tentang aturan bahasa, yang tidak akan kita bahas di sini. Contoh:
- Compiler atau hardware dapat memindahkan akses
memory_order_relaxed
ke (tetapi tidak keluar dari) bagian penting yang dibatasi oleh rilis dan akuisisi kunci. Ini berarti dua penyimpananmemory_order_relaxed
dapat terlihat tidak berurutan, meskipun keduanya dipisahkan oleh bagian yang penting. - Variabel Java umum, jika disalahgunakan sebagai penghitung bersama, dapat terlihat menurun oleh thread lain, meskipun baru ditingkatkan oleh satu thread lainnya. Namun, ini tidak berlaku untuk C++ atomic
memory_order_relaxed
.
Dengan memperhatikan hal tersebut sebagai peringatan, di sini kami memberikan beberapa idiom yang tampaknya mencakup banyak kasus penggunaan untuk operasi atomic dengan urutan lemah. Beberapa di antaranya hanya berlaku pada C++.
Akses bukan race
Sangatlah umum jika variabel bersifat atomic karena terkadang variabel dibaca bersamaan dengan operasi tulis, tetapi tidak semua akses memiliki masalah ini.
Misalnya, sebuah variabel mungkin harus atomic karena dibaca di luar bagian penting, tetapi semua update dilindungi oleh kunci. Dalam hal ini, proses baca yang kebetulan dilindungi oleh kunci yang sama tidak dapat melakukan race satu sama lain karena tidak mungkin ada proses tulis yang terjadi bersamaan. Dalam kasus semacam ini, akses bukan race (dalam hal ini pemuatan), dapat dianotasikan dengan memory_order_relaxed
tanpa mengubah ketepatan kode C++.
Implementasi kunci telah memberlakukan pengurutan memori yang diperlukan sehubungan dengan akses oleh thread lain, dan memory_order_relaxed
menentukan bahwa tidak ada batasan pengurutan tambahan yang perlu diberlakukan untuk akses atomic.
Tidak ada padanan yang serupa dengan ini pada Java.
Ketepatan hasil tidak diandalkan
Saat kita menggunakan pemuatan race hanya untuk menghasilkan petunjuk, secara umum tidak masalah jika pengurutan memori tidak diberlakukan pada pemuatan tersebut. Jika nilainya tidak dapat diandalkan, kita juga tidak dapat menggunakan hasilnya untuk menyimpulkan apa pun tentang variabel lain. Karena itu, tidak masalah jika pengurutan memori tidak dijamin, dan pemuatan disertakan dengan argumen memory_order_relaxed
.
Salah satu contoh umum dari ini adalah penggunaan C++ compare_exchange
untuk menggantikan x
dengan f(x)
secara atomic.
Pemuatan awal x
untuk menghitung f(x)
tidak harus andal. Jika prosesnya salah, compare_exchange
akan gagal dan kita akan mencobanya lagi.
Pemuatan awal x
boleh menggunakan argumen memory_order_relaxed
; hanya pengurutan memori untuk compare_exchange
sebenarnya yang diutamakan.
Data yang dimodifikasi secara atomic tetapi tidak terbaca
Terkadang data dimodifikasi secara paralel oleh banyak thread, tetapi tidak diperiksa sampai komputasi paralel tersebut selesai. Contoh yang tepat dari ini adalah penghitung yang ditingkatkan secara atomic (misalnya menggunakan C++ fetch_add()
atau C atomic_fetch_add_explicit()
) oleh beberapa thread secara paralel, tetapi hasil panggilan tersebut selalu diabaikan. Nilai yang dihasilkan hanya dibaca pada akhirnya, setelah semua update selesai.
Dalam kasus ini, tidak mungkin untuk mengetahui apakah akses ke data ini telah diubah urutannya atau tidak, dan oleh karena itu, kode C++ dapat menggunakan argumen memory_order_relaxed
.
Penghitung peristiwa sederhana adalah contoh umum dari ini. Karena sudah sangat umum, kasus ini patut diamati:
- Penggunaan
memory_order_relaxed
meningkatkan performa, tetapi mungkin tidak mengatasi masalah performa yang paling penting: Setiap update memerlukan akses eksklusif ke baris cache yang menampung penghitung. Cara ini menghasilkan cache yang tidak ditemukan setiap kali thread baru mengakses penghitung. Jika update sering dilakukan dan berjalan secara berselang antar-thread, akan jauh lebih cepat untuk menghindari update penghitung bersama setiap saat dengan menggunakan, misalnya, penghitung thread lokal dan menjumlahkannya pada akhir operasi. - Teknik ini dapat dipadukan dengan bagian sebelumnya: Nilai perkiraan dan nilai yang tidak dapat diandalkan bisa saja dibaca selagi diupdate, dengan semua operasi menggunakan
memory_order_relaxed
. Namun, nilai yang dihasilkan harus benar-benar dianggap tidak dapat diandalkan sepenuhnya. Hanya karena penghitungan tampaknya telah ditingkatkan satu kali bukan berarti thread lain dapat dianggap telah mencapai titik di mana kenaikan telah dilakukan. Urutan kenaikan mungkin telah diubah dengan kode sebelumnya. (Sedangkan untuk kasus serupa yang kami sebutkan di atas, C++ menjamin bahwa pemuatan kedua dari penghitung tersebut tidak akan menampilkan nilai yang lebih kecil daripada pemuatan sebelumnya dalam thread yang sama. Tentunya, kecuali penghitung kelebihan muatan). - Sangatlah wajar untuk menemukan kode yang mencoba menghitung nilai perkiraan penghitung dengan melakukan operasi baca dan tulis atomic (atau bukan atomic) individual, tetapi tidak membuat kenaikan sebagai atomic keseluruhan. Argumen umumnya adalah proses ini "cukup mendekati" penghitung performa atau semacamnya. Biasanya tidak. Jika update cukup sering dilakukan (yang mungkin menjadi perhatian Anda), sebagian besar penghitungan biasanya hilang. Pada perangkat quad core, lebih dari setengah penghitungan biasanya hilang. (Latihan mudah: buat skenario dua thread di mana penghitung diupdate satu juta kali, tetapi nilai penghitung akhirnya adalah satu.)
Komunikasi flag sederhana
Penyimpanan memory_order_release
(atau operasi baca-modifikasi-tulis) memastikan bahwa jika pemuatan memory_order_acquire
(atau operasi baca-modifikasi-tulis) selanjutnya membaca nilai yang ditulis, pemuatan tersebut juga akan mengamati semua penyimpanan (biasa atau atomic) yang mendahului penyimpanan memory_order_release
A. Sebaliknya, semua pemuatan yang mendahului memory_order_release
tidak akan mengamati penyimpanan apa pun yang mengikuti pemuatan memory_order_acquire
.
Tidak seperti memory_order_relaxed
, kondisi ini memungkinkan operasi atomic tersebut digunakan untuk mengomunikasikan progres satu thread ke thread lainnya.
Misalnya, kita dapat menulis ulang contoh double-checked locking di atas pada C++ sebagai
class MyClass { private: atomic<Helper*> helper {nullptr}; mutex mtx; public: Helper* getHelper() { Helper* myHelper = helper.load(memory_order_acquire); if (myHelper == nullptr) { lock_guard<mutex> lg(mtx); myHelper = helper.load(memory_order_relaxed); if (myHelper == nullptr) { myHelper = new Helper(); helper.store(myHelper, memory_order_release); } } return myHelper; } };
Pemuatan acquire dan penyimpanan release memastikan bahwa jika kita melihat helper
non-null, kita juga akan melihat bahwa kolom-kolomnya sudah diinisialisasi dengan benar.
Kita juga telah menggabungkan pengamatan sebelumnya di mana pemuatan non-race dapat menggunakan memory_order_relaxed
.
Programmer Java dapat dianggap menyatakan helper
sebagai java.util.concurrent.atomic.AtomicReference<Helper>
dan menggunakan lazySet()
sebagai penyimpanan release. Operasi pemuatan akan tetap menggunakan panggilan get()
biasa.
Dalam kedua kasus ini, modifikasi performa kita terpusat pada jalur inisialisasi, yang tidak mungkin akan berpengaruh penting terhadap performa. Kompromi yang lebih mudah dibaca mungkin seperti ini:
Helper* getHelper() { Helper* myHelper = helper.load(memory_order_acquire); if (myHelper != nullptr) { return myHelper; } lock_guard<mutex> lg(mtx); if (helper == nullptr) { helper = new Helper(); } return helper; }
Kode ini menyediakan jalur cepat yang sama, tetapi memanfaatkan operasi konsisten berurutan default pada jalur lambat yang tidak berpengaruh penting terhadap performa.
Bahkan di sini, helper.load(memory_order_acquire)
kemungkinan akan menghasilkan kode yang sama pada arsitektur yang didukung Android terkini sebagai referensi biasa (konsisten berurutan) ke helper
. Pengoptimalan yang benar-benar bermanfaat di sini adalah penambahan myHelper
untuk meniadakan pemuatan kedua, meskipun compiler mendatang mungkin dapat menanganinya secara otomatis.
Pengurutan acquire/release tidak menghindarkan penyimpanan dari keterlambatan yang terlihat, dan tidak memastikan bahwa penyimpanan akan terlihat oleh thread lain dalam urutan yang konsisten. Akibatnya, pengurutan tersebut tidak mendukung pola coding yang rumit tetapi cukup umum yang dicontohkan dalam algoritme mutual exclusion Dekker: Semua thread terlebih dahulu menetapkan flag yang menunjukkan maksud untuk melakukan sesuatu; jika sebuah thread t melihat bahwa tidak ada thread lain yang mencoba melakukan sesuatu, thread tersebut dapat melanjutkan dengan aman karena tidak ada gangguan. Thread lainnya tidak ada yang dapat melanjutkan karena flag t masih ditetapkan. Proses ini akan gagal jika flag diakses menggunakan pengurutan acquire/release, karena pengurutan tersebut tidak dapat mencegah terlihatnya flag thread oleh thread lainnya yang telah keliru melanjutkan. memory_order_seq_cst
default dapat mencegahnya.
Kolom immutable
Jika sebuah kolom objek diinisialisasi pada penggunaan pertama dan tidak pernah diubah lagi, ada kemungkinan untuk menginisialisasi dan kemudian membaca kolom objek tersebut menggunakan akses dengan urutan yang lemah. Pada C++, kolom objek tersebut dapat dideklarasikan sebagai atomic
serta diakses menggunakan memory_order_relaxed
atau pada Java, dan dapat dideklarasikan tanpa volatile
serta diakses tanpa langkah-langkah khusus. Ini mengharuskan semua hal berikut terpenuhi:
- Kolom objek tersebut harus dapat mengetahui berdasarkan nilai kolomnya sendiri, apakah nilai tersebut telah diinisialisasi atau belum. Untuk mengakses kolom ini, nilai uji-dan-tampilkan jalur cepat harus membaca kolom tersebut satu kali. Pada Java, poin yang disebut terakhir ini sangat penting. Meskipun kolom dinyatakan sudah diinisialisasi, pemuatan kedua dapat membaca nilai sebelumnya yang belum diinisialisasi. Pada C++, aturan "baca satu kali" ini hanyalah praktik yang baik.
- Inisialisasi dan pemuatan berikutnya harus merupakan operasi atomic, dalam arti, update parsial tidak boleh terlihat. Untuk Java, kolom sebaiknya bukan
long
ataudouble
. Untuk C++, penetapan atomic diwajibkan; mengonstruksinya di tempat tidak akan berhasil karena konstruksiatomic
bukanlah atomic. - Inisialisasi berulang harus aman karena banyak thread dapat membaca nilai yang belum diinisialisasi secara serentak. Pada C++, cara ini biasanya mengikuti persyaratan "trivially copyable" yang berlaku untuk semua jenis operasi atomic; jenis operasi dengan pointer dimiliki dan bertingkat akan memerlukan dealokasi konstruktor salinan, dan tidak akan "trivially copyable". Untuk Java, jenis referensi tertentu dapat diterima:
- Referensi Java dibatasi pada jenis immutable yang hanya memuat kolom akhir. Konstruktor dari jenis immutable tidak boleh memublikasikan referensi ke objek. Dalam hal ini, aturan kolom akhir Java memastikan bahwa jika melihat referensi, pembaca juga akan melihat kolom akhir yang telah diinisialisasi. C++ tidak memiliki analogi untuk aturan ini, dan pointer ke objek dimiliki juga tidak dapat diterima karena alasan ini (selain melanggar persyaratan "trivially copyable").
Catatan penutup
Meskipun tidak sekadar membahas hal-hal umum, dokumen ini tidak memberikan pembahasan yang terlalu mendalam. Ini adalah topik yang sangat luas dan dalam. Beberapa hal yang dapat dipelajari lebih lanjut:
- Model memori Java dan C++ aktual dinyatakan dari segi hubungan happens-before yang menentukan kapan dua tindakan dijamin akan terjadi dalam urutan tertentu. Saat kita mendefinisikan data race, secara informal kita membahas dua akses memori yang terjadi "secara bersamaan".
Secara resmi, data race diartikan sebagai tidak ada yang terjadi sebelum yang lain terjadi.
Mempelajari definisi aktual happens-before dan synchronizes-with sangat berguna dalam Model Memori C++ atau Java.
Meskipun umumnya gagasan intuitif "secara bersamaan" cukup bagus, definisi ini sangat bermanfaat, terutama jika Anda mempertimbangkan untuk menggunakan operasi atomic dengan urutan lemah pada C++. (Spesifikasi java saat ini hanya menetapkan
lazySet()
dengan sangat informal.) - Pelajari apa yang boleh dan tidak boleh dilakukan compiler saat mengubah urutan kode. (Spesifikasi JSR-133 memiliki beberapa contoh bagus tentang transformasi sah yang mengarah pada hasil yang tidak terduga.)
- Ketahui cara menulis class immutable (tidak dapat diubah) pada Java dan C++. (Ada lebih banyak pembahasan daripada sekadar "jangan mengubah apa pun setelah mengonstruksi".)
- Internalisasikan rekomendasi pada bagian Concurrency Effective Java, 2nd Edition. (Misalnya, Anda harus menghindari memanggil metode yang dimaksudkan untuk diganti saat berada dalam blok yang disinkronkan.)
- Baca
java.util.concurrent
danjava.util.concurrent.atomic
API secara menyeluruh untuk mengetahui apa yang tersedia. Pertimbangkan untuk menggunakan anotasi serentak seperti@ThreadSafe
dan@GuardedBy
(dari net.jcip.annotations).
Bagian Bacaan Lebih Lanjut pada lampiran menyediakan link mengenai dokumen dan situs yang akan menjelaskan topik-topik ini secara lebih mendalam.
Lampiran
Mengimplementasikan penyimpanan sinkronisasi
(Meskipun sebagian besar programmer tidak mengimplementasikannya, pembahasan ini dapat memberikan pencerahan.)
Untuk jenis bawaan kecil seperti int
, dan hardware yang didukung oleh Android, instruksi pemuatan dan penyimpanan biasa memastikan bahwa penyimpanan akan terlihat baik secara keseluruhan, maupun tidak sama sekali, oleh prosesor lain yang memuat lokasi yang sama. Karena itu, beberapa gagasan dasar tentang "atomicity" diberikan secara gratis.
Seperti yang kita lihat sebelumnya, ini tidaklah cukup. Untuk memastikan konsistensi urutan, kita juga perlu mencegah pengubahan urutan operasi dan memastikan bahwa operasi memori terlihat oleh proses lain dalam urutan yang konsisten. Ternyata poin yang disebut terakhir ini berjalan otomatis pada hardware yang didukung Android, asalkan kita membuat pilihan bijak untuk menerapkan poin yang disebut sebelumnya; karena itu, kita cenderung mengabaikannya di sini.
Urutan operasi memori dipertahankan dengan mencegah pengubahan urutan oleh compiler, dan mencegah pengubahan urutan oleh hardware. Di sini, kita berfokus pada pengubahan urutan oleh hardware.
Pengurutan memori pada ARMv7, x86, dan MIPS diberlakukan dengan instruksi "fence" yang secara garis besar mencegah terlihatnya instruksi yang mengikuti fence sebelum instruksi yang mendahului fence. (Ini juga biasa disebut instruksi "barrier", yang sering disamakan dengan pthread_barrier
-style barrier, yang memiliki jauh lebih banyak kegunaan dibandingkan barrier ini.) Makna sebenarnya dari instruksi fence merupakan topik yang cukup rumit, yang harus mencakup bagaimana jaminan yang diberikan oleh berbagai jenis fence akan berinteraksi, dan bagaimana jaminan tersebut bergabung dengan jaminan pengurutan lainnya yang biasanya disediakan oleh hardware. Ini adalah ringkasan tingkat lanjut, jadi kita akan mengabaikan detail ini.
Jenis jaminan pengurutan yang paling dasar adalah yang disediakan oleh operasi atomic C++ memory_order_acquire
dan memory_order_release
: Operasi memori yang mendahului penyimpanan release harus terlihat setelah pemuatan acquire. Di ARMv7, langkah ini diterapkan dengan:
- Mendahului instruksi penyimpanan dengan instruksi fence yang sesuai. Langkah ini mencegah terjadinya pengubahan urutan pada semua akses memori sebelumnya akibat instruksi penyimpanan. (Juga mencegah pengubahan urutan yang tidak diperlukan akibat instruksi penyimpanan yang lebih baru.)
- Mengikuti instruksi pemuatan dengan instruksi fence yang sesuai, sehingga mencegah terjadinya pengubahan urutan pemuatan akibat akses selanjutnya. (Dan sekali lagi memberikan pengurutan yang tidak perlu akibat pemuatan yang lebih awal.)
Bersama-sama, langkah ini sudah cukup untuk pengurutan acquire/release pada C++.
Hal itu diperlukan, tetapi tidak memadai, untuk Java volatile
atau C++ atomic
yang konsisten berurutan.
Untuk memahami apa lagi yang kita perlukan, pertimbangkan fragmen algoritme Dekker yang kami sebutkan sekilas di depan.
flag1
dan flag2
adalah variabel C++ atomic
atau variabel Java volatile
. Keduanya memiliki nilai awal false.
Thread 1 | Thread 2 |
---|---|
flag1 = true |
flag2 = true |
Konsistensi urutan menyiratkan bahwa salah satu penetapan ke flag
n harus dieksekusi terlebih dahulu, dan terlihat oleh pengujian di thread lainnya. Dengan demikian, kita tidak akan pernah melihat thread ini mengeksekusi "hal-hal penting" secara bersamaan.
Namun fencing yang diperlukan untuk pengurutan acquire-release hanya menambahkan fence pada awal dan akhir setiap thread, dan langkah itu tidak terlalu optimal di sini. Kita juga perlu memastikan bahwa jika penyimpanan volatile
/atomic
diikuti dengan pemuatan volatile
/atomic
, urutan keduanya tidak diubah.
Hal ini biasanya diterapkan dengan menambahkan fence bukan hanya sebelum penyimpanan yang konsisten berurutan, tetapi juga setelahnya.
(Sekali lagi, langkah ini jauh lebih kuat dari yang diperlukan karena fence ini biasanya mengurutkan semua akses memori yang lebih awal dalam kaitannya dengan semua akses memori yang lebih akhir.)
Kita dapat mengaitkan fence tambahan ini dengan pemuatan yang konsisten berurutan. Karena penyimpanan lebih jarang dilakukan, konvensi yang kami jelaskan lebih umum dan digunakan di Android.
Seperti yang dibahas pada bagian sebelumnya, kita perlu memasukkan barrier penyimpanan/pemuatan di antara kedua operasi. Kode yang dieksekusi di VM untuk sebuah akses volatile akan terlihat seperti berikut:
muatan volatile | penyimpanan volatile |
---|---|
reg = A |
fence for "release" (2) |
Arsitektur mesin yang sebenarnya biasanya menyediakan beberapa jenis fence, yang mengurutkan berbagai jenis akses dan mungkin memiliki biaya yang berbeda. Perbedaan antara pilihan ini tidak begitu kentara, dan dipengaruhi oleh kebutuhan untuk memastikan bahwa penyimpanan terlihat oleh inti lain dalam urutan yang konsisten, dan bahwa pengurutan memori yang diterapkan oleh kombinasi beberapa fence ini disusun dengan benar. Untuk detail lebih lanjut, lihat halaman kumpulan pemetaan atomic ke prosesor aktual dari University of Cambridge.
Pada beberapa arsitektur, terutama x86, barrier "acquire" dan "release" tidak diperlukan karena hardware secara implisit selalu menerapkan pengurutan yang memadai. Oleh karena itu, pada arsitektur x86 hanya fence terakhir (3) yang benar-benar dibuat. Demikian juga pada arsitektur x86, operasi baca-modifikasi-tulis atomic secara implisit mencakup fence yang kuat. Dengan demikian, operasi ini tidak memerlukan fence. Pada ARMv7, semua fence yang kita bahas di atas diperlukan.
ARMv8 menyediakan instruksi LDAR dan STLR yang secara langsung memberlakukan persyaratan volatile Java atau pemuatan dan penyimpanan konsisten berurutan C++. Instruksi tersebut menghindarkan dari masalah pengurutan ulang yang tidak perlu, seperti yang disebutkan di atas. Kode Android 64 bit pada ARM menggunakan instruksi tersebut; kami memilih untuk berfokus pada penempatan fence ARMv7 di sini karena metode ini memberikan lebih banyak pengetahuan mengenai persyaratan yang sebenarnya.
Bacaan lebih lanjut
Halaman web dan dokumen yang memberikan pemahaman lebih luas dan mendalam. Artikel yang secara umum lebih bermanfaat dicantumkan lebih awal.
- Shared Memory Consistency Models: A Tutorial
- Ditulis pada tahun 1998 oleh Adve & Gharachorloo, artikel ini adalah awal yang bagus untuk memulai jika Anda ingin memahami model konsistensi memori lebih dalam.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf - Memory Barriers
- Artikel singkat menarik mengenai ringkasan masalah.
https://en.wikipedia.org/wiki/Memory_barrier - Threads Basics
- Pengantar pemrograman multi-threading pada C++ dan Java, ditulis oleh Hans Boehm. Diskusi tentang data race dan metode sinkronisasi dasar.
http://www.hboehm.info/c++mm/threadsintro.html - Java Concurrency In Practice
- Diterbitkan tahun 2006, buku ini membahas berbagai topik dengan sangat mendetail. Sangat direkomendasikan untuk siapa saja yang menulis kode multi-thread di Java.
http://www.javaconcurrencyinpractice.com - JSR-133 (Java Memory Model) FAQ
- Pengantar umum ke model memori Java, termasuk penjelasan tentang sinkronisasi, variabel volatile, dan konstruksi kolom akhir.
(Sedikit ketinggalan zaman, khususnya terkait pembahasan bahasa lain.)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html - Validity of Program Transformations in the Java Memory Model
- Penjelasan yang agak teknis tentang masalah-masalah lain dalam model memori Java. Masalah ini tidak berlaku untuk program bebas data race.
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf - Overview of package java.util.concurrent
- Dokumentasi untuk paket
java.util.concurrent
. Di dekat bagian bawah halaman terdapat bagian yang berjudul "Memory Consistency Properties" yang menjelaskan jaminan yang diberikan oleh berbagai class.java.util.concurrent
Ringkasan Paket - Java Theory and Practice: Safe Construction Techniques in Java
- Artikel ini membahas secara mendetail risiko melewatkan referensi selama konstruksi objek, dan memberikan panduan terkait konstruktor yang aman untuk thread.
http://www.ibm.com/developerworks/java/library/j-jtp0618.html - Java Theory and Practice: Managing Volatility
- Artikel menarik yang menguraikan apa yang dapat dan tidak dapat Anda lakukan dengan kolom volatile pada Java.
http://www.ibm.com/developerworks/java/library/j-jtp06197.html - The “Double-Checked Locking is Broken” Declaration
- Penjelasan mendetail Bill Pugh tentang bagaimana double-checked locking tidak berfungsi tanpa
volatile
atauatomic
. Termasuk C/C++ dan Java.
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html - [ARM] Barrier Litmus Tests and Cookbook
- Pembahasan tentang masalah SMP ARM, diperjelas melalui cuplikan singkat kode ARM. Jika Anda merasa contoh pada halaman ini terlalu tidak spesifik, atau ingin membaca deskripsi formal instruksi DMB, bacalah ini. Juga menjelaskan instruksi yang digunakan untuk memory barrier pada kode yang dapat dieksekusi (mungkin berguna jika Anda membuat kode sambil menjalankan proses). Perhatikan bahwa ini ditulis sebelum ARMv8, yang juga mendukung instruksi pengurutan memori tambahan dan peralihan ke model memori yang lebih kuat. (Lihat "ARM® Architecture Reference Manual ARMv8, for ARMv8-A architecture profile" untuk detail selengkapnya.)
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf - Linux Kernel Memory Barriers
- Dokumentasi untuk memory barrier kernel Linux. Berisi beberapa contoh yang berguna dan seni ASCII.
http://www.kernel.org/doc/Documentation/memory-barriers.txt - ISO/IEC JTC1 SC22 WG21 (standar C++) 14882 (bahasa pemrograman C++), pasal 1.10 dan klausul 29 ("Atomic operation library")
- Draf standar untuk fitur operasi atomic pada C++. Versi ini mirip dengan standar C++ 14, yang mencakup perubahan minor di area ini dari C++ 11.
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(intro: http://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf) - ISO/IEC JTC1 SC22 WG14 (standar C) 9899 (bahasa pemrograman C) bab 7.16 (“Atomics <stdatomic.h>”)
- Draf standar untuk fitur operasi atomic ISO/IEC 9899-201x pada C.
Untuk detail selengkapnya, lihat juga laporan kerusakan lama.
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf - C/C++11 mappings to processors (University of Cambridge)
- Koleksi Jaroslav Sevcik dan Peter Sewell yang berisi terjemahan atomic C++ ke berbagai kumpulan petunjuk prosesor yang umum.
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html - Dekker’s algorithm
- "Solusi tepat pertama yang diketahui untuk masalah mutual exclusion dalam pemrograman serentak". Artikel wikipedia ini menyediakan algoritme lengkap, dengan pembahasan tentang cara mengupdate algoritme tersebut agar berfungsi dengan compiler pengoptimalan modern dan hardware SMP.
https://en.wikipedia.org/wiki/Dekker's_algorithm - Comments on ARM vs. Alpha and address dependencies
- Email pada milis arm-kernel dari Catalin Marinas. Berisi ringkasan menarik tentang dependensi alamat dan kontrol.
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html - What Every Programmer Should Know About Memory
- Artikel sangat panjang dan mendetail dari Ulrich Drepper tentang berbagai jenis memori, terutama cache CPU.
http://www.akkadia.org/drepper/cpumemory.pdf - Reasoning about the ARM weakly consistent memory model
- Makalah ini ditulis oleh Chong & Ishtiaq dari ARM, Ltd. Mereka mencoba menjelaskan model memori SMP ARM secara menyeluruh tetapi mudah dipahami. Definisi "observabilitas" yang digunakan di sini berasal dari makalah ini. Sekali lagi, makalah ini ditulis sebelum ARMv8.
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711 - The JSR-133 Cookbook for Compiler Writers
- Doug Lea menulis ini sebagai pendamping dokumentasi JSR-133 (Java Memory Model). Berisi kumpulan pedoman implementasi untuk model memori Java yang dahulu digunakan oleh banyak penulis compiler, dan sekarang masih banyak dikutip serta memberikan data.
Sayangnya, empat variasi fence yang dibahas di sini tidak cocok untuk arsitektur yang didukung Android, dan pemetaan di atas C++ 11 sekarang menjadi sumber urutan langkah yang lebih baik, termasuk untuk Java.
http://g.oswego.edu/dl/jmm/cookbook.html - x86-TSO: A Rigorous and Usable Programmer’s Model for x86 Multiprocessors
- Deskripsi tepat tentang model memori x86. Sayangnya, deskripsi tepat tentang model memori ARM ini jauh lebih rumit.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf