Pedoman dasar SMP untuk Android

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 multithread untuk sistem multiprosesor simetris pada bahasa pemrograman C, C++, dan Java (selanjutnya, agar lebih singkat, disebut "Java"). Dokumen ini dimaksudkan sebagai pedoman dasar bagi developer aplikasi Android, bukan sebagai diskusi lengkap mengenai subjek tersebut.

Pengantar

SMP adalah singkatan dari "Symmetric Multi-Processor" atau multiprosesor simetris. SMP menggambarkan suatu desain di mana dua atau lebih inti CPU identik berbagi akses ke memori utama. Hingga beberapa tahun yang lalu, semua perangkat Android hanya memiliki prosesor tunggal (UP/Uni-Processor).

Sebagian besar — jika tidak 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 gagal secara teratur saat dua atau lebih thread berjalan bersamaan pada inti berbeda. Terlebih lagi, kode mungkin 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 rusak parah di ARM. Kode dapat mulai gagal 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 ini 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 pada bagian selanjutnya, di sini detail biasanya tidak penting.

Lihat Bacaan lebih lanjut di bagian akhir dokumen untuk mendapatkan petunjuk tentang menangani subjek ini secara yang lebih menyeluruh.

Model konsistensi memori, atau biasanya hanya disebut "model memori", mendeskripsikan 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 dalam urutan seperti itu.

Model yang paling umum di kalangan programmer adalah konsistensi urutan (sequential consistency), yang dideskripsikan sebagai berikut (Adve & Gharachorloo):

  • Semua operasi memori tampak dijalankan satu per satu
  • Semua operasi pada thread tunggal tampak dijalankan sesuai urutan yang dideskripsikan 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 yang terkait, satu instruksi per akses. Untuk sederhananya, kita juga akan mengasumsikan bahwa setiap thread berjalan 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 tidak ada cara bagi kode yang berjalan pada perangkat itu untuk mengetahui bahwa CPU melakukan hal lain selain menjalankan instruksi tersebut apa adanya. (Kita akan mengabaikan I/O driver perangkat yang dipetakan memori.)

Untuk mengilustrasikan poin-poin di atas, mari kita pertimbangkan cuplikan singkat kode yang biasanya disebut tes litmus.

Berikut ini contoh sederhana, dengan kode yang berjalan pada dua thread:

Thread 1 Thread 2
A = 3
B = 5
reg0 = B
reg1 = A

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 dijalankan 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 berjalan pada inti CPU yang berbeda. Sebaiknya Anda selalu menggunakan asumsi ini ketika memikirkan kode multi-thread.

Konsistensi urutan menjamin bahwa, setelah kedua thread selesai dijalankan, 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 (dijalankan bersamaan)
reg0=5, reg1=0 tidak mungkin

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 dijalankan secara selang-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, adalah wajar bagi hardware untuk mem-buffer penyimpanan pada perjalanannya ke memori, sehingga penyimpanan tidak langsung mencapai memori dan menjadi terlihat oleh inti lainnya.

Detail ini memiliki variasi yang substansial. Sebagai contoh, meskipun tidak konsisten berurutan, x86 tetap menjamin bahwa reg0=5 dan reg1=0 tidaklah 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 bisa 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, dan karenanya 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, pemuatan ke reg0 dan reg1 dapat diubah urutannya.

Pengubahan urutan akses ke lokasi memori yang berbeda, entah pada hardware atau 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 multithread.

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 telah diubah urutannya. Tetapi 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 jika Anda menjalankan 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 urutan". Sayangnya, masalah lain dapat timbul 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 memberi tahu compiler sebaliknya, compiler dan hardware pasti akan memberikan hasil yang konsisten berurutan. Ini tidak benar-benar berarti bahwa compiler dan hardware menghindari pengubahan urutan akses memori. Hal ini hanya berarti bahwa, jika mengikuti aturan, Anda tidak akan tahu bahwa akses memori diubah urutannya. Ini sama seperti memberi tahu Anda bahwa sosis itu makanan lezat dan menggugah selera, selama Anda berjanji 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 setidaknya dua thread mengakses data biasa yang sama secara bersamaan, dan setidaknya salah satu thread tersebut memodifikasi data itu. Yang kami maksud "data biasa" ini adalah sesuatu yang secara khusus bukan merupakan objek sinkronisasi yang dimaksudkan untuk komunikasi thread. Mutex, variabel kondisi, Java volatile, atau objek atomic C++ bukanlah data biasa, dan aksesnya dibolehkan 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 urutan. 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 operasi tidak diubah urutannya, kedua kondisi akan dievaluasi ke false, dan tidak ada variabel yang diperbarui. Dengan demikian, tidak mungkin terjadi data race. Anda tidak perlu mengkhawatirkan apa yang mungkin terjadi jika pemuatan dari A dan penyimpanan ke B pada Thread 1 diubah urutannya. Compiler tidak diizinkan untuk mengubah urutan Thread 1 dengan menulisnya ulang sebagai "B = true; if (!A) B = false". Hal 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 secara bersamaan membacanya 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 menyebabkan data race kecuali jika ada akses serentak ke container yang sama, dan setidaknya salah satu akses tersebut mengubah container. Mengupdate sebuah set<T> pada satu thread dan secara bersamaan membacanya 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 set<T> lain pada thread yang lain, tidak akan mengakibatkan data race, karena dalam kasus ini library memastikan untuk tidak menyebabkan data race (tingkat rendah).

Biasanya, akses serentak ke kolom yang berbeda dalam sebuah struktur data tidak akan menyebabkan data race. Namun, ada satu pengecualian penting untuk aturan ini: Rangkaian kolom bit yang saling berdekatan di C atau C++ diperlakukan sebagai satu "lokasi memori". Untuk keperluan mengetahui keberadaan data race, mengakses kolom bit dalam rangkaian semacam ini akan diperlakukan seolah-olah mengakses semua kolom. Ini mencerminkan 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 (C++11 std::mutex atau pthread_mutex_t), atau blok synchronized pada Java dapat digunakan untuk memastikan bahwa bagian kode tertentu tidak berjalan bersamaan dengan bagian kode lain yang mengakses data yang sama. Kami menyebut fitur ini dan fasilitas serupa lainnya secara umum dengan "kunci". Mendapatkan kunci tertentu secara konsisten sebelum mengakses sebuah struktur data bersama, dan kemudian melepaskan kunci itu, 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 yang paling umum untuk mencegah data race. Penggunaan blok synchronized pada Java atau lock_guard atau unique_lock pada C++ memastikan bahwa kunci dilepas dengan tepat jika terjadi pengecualian.
Variabel volatile/atomic
Java menyediakan kolom volatile yang mendukung akses serentak tanpa menyebabkan data race. Sejak 2011, C dan C++ mendukung kolom dan variabel atomic 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, variabel volatile atau atomic tidak dapat digunakan langsung untuk mencegah thread lain mengganggu urutan kode yang lebih panjang.

Penting untuk dicatat 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. Variabel volatile pada C++ dimaksudkan untuk register perangkat dan sejenisnya.

Variabel atomic pada C/C++ atau variabel volatile pada Java 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, maka cuplikan berikut ini bebas data race:

Thread 1 Thread 2
A = ...
  flag = true
while (!flag) {}
... = A

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 tes 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 melibatkan 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:
  1. 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 volatile flag pada contoh di atas, Thread 2 dapat melihat lokasi memori A 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 = true
    reg0 = flag; sementara (!reg0) {}
    ... = A
    Saat menjalankan proses debug, Anda mungkin melihat loop ini terus berlanjut meskipun flag ditetapkan ke true.
  2. C++ menyediakan fasilitas untuk menyesuaikan konsistensi urutan secara eksplisit sekalipun tidak terjadi race. Operasi atomic dapat menerima argumen memory_order_... eksplisit. Demikian pula, paket java.util.concurrent.atomic menyediakan kumpulan fasilitas serupa yang lebih terbatas, terutama lazySet(). 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. Kami membahasnya sekilas di bawah.
  3. Beberapa kode C dan C++ ditulis dalam gaya lama, yang tidak sepenuhnya konsisten dengan standar bahasa terkini, di mana variabel volatile digunakan sebagai pengganti atomic, dan pengurutan memori dilarang secara eksplisit melalui penyisipan elemen yang disebut fence atau barrier. Hal 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 bisa jadi 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 sesak 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, volatile pada C++ dan C tidak dirancang untuk komunikasi thread.

Pada C dan C++, akses ke data volatile dapat diubah urutannya 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. volatile pada C 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. Hal 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 std::mutex pada C++11) daripada operasi atomic, tetapi kami akan menerapkan metode yang disebut terakhir ini untuk mengilustrasikan 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
            ...
        }
    }

Idenya 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);

Hal 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 sekilas 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 kami hanya membahasnya secara singkat di bawah ini. Parahnya lagi, pakar yang menentukan arti kode tersebut tidak lagi percaya bahwa spesifikasi itu benar. (Spesifikasi tersebut cocok untuk kode bebas data race).

Untuk sementara, kita akan mematuhi model bebas data race, yang untuknya Java memberikan jaminan yang sama dengan C dan C++. 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 satunya selesai.

Seperti disebutkan di atas, volatile T pada Java sama dengan atomic<T> pada C++11. Akses serentak ke kolom volatile diizinkan, dan tidak menyebabkan data race. Dengan mengabaikan lazySet() dll. serta data race, menjadi tugas Java VM 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, maka thread 2 juga dijamin melihat semua operasi tulis yang dibuat sebelumnya oleh thread 1. Dari segi efek memori, menulis ke volatile mirip dengan pelepasan monitor, sedangkan membaca dari volatile mirip dengan pemerolehan monitor.

Ada satu perbedaan penting dari atomic pada C++: Jika kita menulis volatile int x; di Java, maka x++ adalah 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:

  1. reg = mValue
  2. reg = reg + 1
  3. mValue = reg

Jika dua thread berjalan di incr() secara bersamaan, salah satu update dapat hilang. Untuk membuat kenaikan atomic, kita perlu mendeklarasikan incr() sebagai “disinkronkan”.

Tetapi itu tetap tidak berhasil, terutama di SMP. Data race tetap terjadi, di mana get() dapat mengakses mValue dalam waktu yang sama 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 rebutan kunci, yang dapat menghambat performa. Daripada mendeklarasikan get() sebagai disinkronkan, kita dapat mendeklarasikan mValue sebagai "volatile". (Catatan: incr() tetap harus menggunakan synchronize karena mValue++ bukan operasi atomic tunggal). Hal ini juga menghindarkan dari semua data race, sehingga konsistensi urutan dapat dipertahankan. incr() akan menjadi sedikit lebih lambat, karena 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 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 urutan 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 menjalankan pemuatan volatile MyClass.sGoodies yang diikuti dengan pemuatan non-volatile sGoodies.x. Jika Anda membuat referensi lokal MyGoodies localGoods = sGoodies, maka z = localGoods.x selanjutnya tidak akan menjalankan pemuatan volatile apa pun.

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 sekali, 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, sehingga kita hanya melakukan bagian itu jika helper bernilai nol.

Instance ini mengandung data race pada kolom helper. Instance ini dapat ditetapkan serentak dengan helper == null pada thread lainnya.

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 bisa mencegah hardware atau compiler untuk mengubah urutan penyimpanan ke helper dengan penyimpanan ke kolom x/y. Thread lain dapat menemukan helper bukan nol, 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:

  1. Lakukan implementasi sederhana dan hapus pemeriksaan luar. Hal ini memastikan bahwa kita tidak perlu memeriksa nilai helper di luar blok yang disinkronkan.
  2. 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 meluangkan waktu 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, maka thread tersebut tidak bisa 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 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 yang tepat, yang menyediakan perilaku yang benar (konsisten berurutan, kecuali jika 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 muncul dalam loop.

Sebaiknya hindari penggunaan fungsi atomic secara langsung, kecuali jika struktur data yang Anda implementasikan sangat sederhana, misalnya penghitung. Penguncian dan pembukaan kunci pthread mutex masing-masing memerlukan satu operasi atomic, dan sering lebih murah daripada satu peristiwa cache yang tidak ditemukan, jika tidak terjadi perebutan; 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 mengandung bug setelah diterapkan. Hindari hal ini 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 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 immutable 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 secara khususnya pentingnya mendeklarasikan kolom Java sebagai “final" (Bloch).

Sekalipun objek bersifat immutable, ingatlah bahwa mengomunikasikannya ke thread lain tanpa melakukan sinkronisasi apa pun merupakan data race. Kondisi ini terkadang dapat diterima pada Java (lihat di bawah), tetapi memerlukan kecermatan 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 immutable 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 synchronized pada Java atau lock_guard / unique_lock pada C++ sebaiknya digunakan untuk melindungi akses ke kolom apa pun 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 "memublikasi" referensi ke sebuah objek (artinya, menyediakannya untuk thread lain) dalam konstruktor objek tersebut. Langkah ini tidak seberapa penting pada C++ atau jika Anda mematuhi saran "tidak ada data race" kami pada lingkungan Java. Tetapi saran tersebut bagus, dan sangat penting, jika kode Java Anda dijalankan dalam konteks lain di mana model keamanan Java sangat diutamakan, dan kode 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 dalam bagian selanjutnya. Lihat (Safe Construction Techniques in Java) untuk penjelasan selengkapnya.

Sedikit lebih lanjut tentang urutan memori yang lemah

C++11 dan yang lebih baru menyediakan mekanisme eksplisit untuk menyesuaikan jaminan konsistensi urutan 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 memberikan 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 sementara.

Metode lazySet pada Java.util.concurrent.atomic mirip dengan penyimpanan memory_order_release pada C++. 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 ini kecuali jika 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 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 pelepasan dan pemerolehan kunci. Hal ini berarti dua penyimpanan memory_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 memory_order_relaxed atomic pada C++.

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 bahwa variabel bersifat atomic, karena terkadang variabel membaca bersamaan dengan menulis, 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 apa-apa jika pengurutan memori tidak diberlakukan pada pemuatan itu. Jika nilainya tidak dapat diandalkan, kita juga tidak dapat menggunakan hasilnya untuk menyimpulkan apa pun tentang variabel lain. Karena itu, tidak apa-apa jika pengurutan memori tidak dijamin, dan pemuatan disertakan dengan argumen memory_order_relaxed.

Salah satu contoh umum dari hal ini adalah penggunaan compare_exchange pada C++ 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 tepat dari hal ini adalah penghitung yang ditingkatkan secara atomic (misalnya menggunakan fetch_add() pada C++ atau atomic_fetch_add_explicit() pada C) oleh beberapa thread secara paralel, tetapi hasil panggilan tersebut selalu diabaikan. Nilai yang dihasilkan hanya dibaca di akhir, setelah semua update selesai.

Dalam kasus ini, tidak ada cara 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 hal 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. Hal ini menghasilkan cache yang tidak ditemukan setiap kali thread baru mengakses penghitung. Jika update sering dilakukan dan berjalan selang-seling antar-thread, akan jauh lebih cepat untuk menghindari update penghitung bersama setiap saat dengan menggunakan, misalnya, penghitung thread lokal dan menjumlahkannya di akhir.
  • Teknik ini dapat dikombinasikan dengan bagian sebelumnya: Nilai perkiraan dan nilai yang tidak dapat diandalkan bisa saja dibaca selagi sedang 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 sekali bukan berarti thread lain dapat dianggap telah mencapai titik di mana kenaikan telah dilakukan. Kenaikan mungkin telah diubah urutannya 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 jika penghitung kelebihan muatan).
  • Adalah wajar untuk menemukan kode yang mencoba menghitung nilai perkiraan penghitung dengan melakukan operasi baca dan tulis atomic (atau tidak atomic) individual, tetapi tidak membuat kenaikan sebagai atomic keseluruhan. Argumen umumnya ialah 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, maka 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 bukan nol, maka 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&ltmutex> 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 kentara, dan tidak memastikan bahwa penyimpanan akan terlihat oleh thread lain dalam urutan yang konsisten. Akibatnya, pengurutan tersebut tidak mendukung pola coding yang rumit tapi cukup umum yang dicontohkan dalam algoritme mutual exclusion Dekker: Semua thread mula-mula menetapkan flag yang menunjukkan niat untuk melakukan sesuatu; jika sebuah thread t melihat bahwa tidak ada thread lain yang mencoba melakukan sesuatu, maka 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, maka 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 dan diakses menggunakan memory_order_relaxed atau pada Java, dapat dideklarasikan tanpa volatile dan diakses tanpa langkah-langkah khusus. Ini mengharuskan semua hal berikut dipenuhi:

  • Kolom objek tersebut harus dapat mengetahui berdasarkan nilai kolomnya sendiri, apakah ia telah diinisialisasi atau belum. Untuk mengakses kolom ini, nilai uji-dan-tampilkan jalur cepat akan membaca kolom tersebut sekali. 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 sekali" ini sekadar merupakan praktik yang baik.
  • Baik inisialisasi maupun pemuatan berikutnya harus merupakan operasi atomic, dalam arti, update parsial tidak boleh terlihat. Untuk Java, kolom sebaiknya bukan long atau double. Untuk C++, penetapan atomic diwajibkan; mengonstruksinya di tempat tidak akan berhasil, karena konstruksi atomic tidak bersifat atomic.
  • Inisialisasi berulang harus aman, karena banyak thread dapat membaca nilai yang belum diinisialisasi secara serentak. Pada C++, hal 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 memuat kolom akhir saja. Konstruktor dari jenis immutable tidak boleh memublikasikan referensi ke objek. Dalam hal ini, aturan kolom akhir Java memastikan bahwa jika pembaca melihat referensi, maka 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 Memory Model C++ atau Java. Meskipun gagasan intuitif "secara bersamaan" secara umum cukup bagus, definisi ini instruktif, terutama jika Anda mempertimbangkan untuk menggunakan operasi atomic dengan urutan lemah pada C++. (Spesifikasi Java saat ini hanya menetapkan lazySet() secara 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 yang 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 dan java.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 ke dokumen dan situs yang akan menjelaskan topik-topik ini dengan lebih mendalam.

Lampiran

Mengimplementasikan penyimpanan sinkronisasi

(Meskipun sebagian besar programmer tidak mengimplementasikannya, pembahasan ini dapat mencerahkan.)

Untuk jenis bawaan kecil seperti int, dan hardware yang didukung oleh Android, instruksi pemuatan dan penyimpanan biasa memastikan bahwa penyimpanan akan terlihat entah secara keseluruhan, atau 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, hal ini tidak 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 pertama; 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 dipersamakan dengan pthread_barrier-style barrier, yang memiliki kegunaan jauh lebih banyak 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 tinggi, jadi kita akan mengabaikan detail ini.

Jenis jaminan pengurutan yang paling dasar adalah yang disediakan oleh operasi atomic memory_order_acquire dan memory_order_release pada C++: Operasi memori yang mendahului penyimpanan release harus terlihat setelah pemuatan acquire. Di ARMv7, ini diterapkan dengan:

  • Mendahului instruksi penyimpanan dengan instruksi fence yang sesuai. Hal 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, ini sudah cukup untuk pengurutan acquire/release pada C++. Hal itu diperlukan, tetapi tidak memadai, untuk volatile pada Java atau atomic konsisten berurutan pada C++.

Untuk memahami apa lagi yang kita perlukan, pertimbangkan penggalan algoritme Dekker yang kami sebutkan sekilas di depan. flag1 dan flag2 adalah variabel atomic pada C++ atau variabel volatile pada Java. Keduanya memiliki nilai awal false.

Thread 1 Thread 2
flag1 = true
if (flag2 == false)
    critical-stuff
flag2 = true
if (flag1 == false)
    critical-stuff

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 menjalankan "hal-hal penting" secara bersamaan.

Namun fencing yang diperlukan untuk pengurutan acquire-release hanya menambahkan fence di awal dan akhir setiap thread, dan hal itu tidak membantu di sini. Kita juga perlu memastikan bahwa jika penyimpanan atomic/volatile diikuti dengan pemuatan atomic/volatile, maka keduanya tidak diubah urutannya. Hal ini biasanya diterapkan dengan menambahkan fence tidak hanya sebelum penyimpanan yang konsisten berurutan, tetapi juga setelahnya. (Sekali lagi, ini jauh lebih kuat daripada 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 kita bahas pada bagian sebelumnya, kita perlu memasukkan barrier penyimpanan/pemuatan di antara kedua operasi. Kode yang dieksekusi pada VM untuk sebuah akses volatile akan terlihat seperti ini:

muatan volatile penyimpanan volatile
reg = A
fence for "acquire" (1)
fence for "release" (2)
A = reg
fence for later atomic load (3)

Arsitektur mesin 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, silakan 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. Jadi, 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++. Hal ini menghindarkan dari masalah pengurutan ulang yang tidak perlu yang kami sebutkan di atas. Kode Android 64 bit pada ARM menggunakan ini; 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 tahun 1995 oleh Adve & Gharachorloo, dokumen ini merupakan awal tepat jika Anda ingin memahami model konsistensi memori secara lebih mendalam.
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf
Memory Barriers
Artikel singkat dan menarik yang merangkum berbagai permasalahan.
http://en.wikipedia.org/wiki/Memory_barrier
Threads Basics
Pengantar pemrograman multi-threading pada C++ dan Java, ditulis oleh Hans Boehm. Membahas 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 terperinci. Sangat direkomendasikan bagi developer yang menulis kode multi-threading pada 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 tersebut tidak berlaku untuk program-program yang 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.
Ringkasan Paket java.util.concurrent
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 atau atomic. Mencakup 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 menurut Anda contoh dalam dokumen ini terlalu kurang spesifik, atau ingin membaca deskripsi formal dari 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 ditulis dibuat 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 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. Mencakup beberapa contoh 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 selengkapnya, periksa juga laporan cacat 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 set instruksi 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 konkuren". Artikel wikipedia ini menyediakan algoritme lengkap, dengan pembahasan tentang cara mengupdate algoritme tersebut agar berfungsi dengan compiler pengoptimalan modern dan hardware SMP.
http://en.wikipedia.org/wiki/Dekker's_algorithm
Comments on ARM vs. Alpha and address dependencies
Email pada milis arm-kernel dari Catalin Marinas. Mencakup 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. dan mencoba menjelaskan model memori SMP ARM secara menyeluruh tetapi mudah dipahami. Definisi "observabilitas" yang digunakan dalam artikel ini 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 pemahaman. Sayangnya, empat variasi fence yang dibahas di sini tidak cocok untuk arsitektur yang didukung Android, dan pemetaan di atas C++ 11 sekarang menjadi sumber resep 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. Deskripsi tepat tentang model memori ARM sayangnya jauh lebih rumit.
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf