Historia: Instrukcja select w Go została wprowadzona w celu wsparcia semantyki Procesów Komunikujących się (CSP), umożliwiając goroutines multiplikowanie operacji kanałowych. Kompilator przekształca select w wywołania do runtime.selectgo, które zarządza złożoną logiką wyboru gotowych kanałów lub blokowania, aż jeden z nich stanie się gotowy.
Problem: Powszechne jest błędne przekonanie, że dodanie przypadku default eliminuje wszelkie koszty synchronizacji, co czyni operacje kanałowe wolnymi od blokad. To zamieszanie wynika z mylenia „nienblokującego” (natychmiastowy powrót, jeśli żaden przypadek nie jest gotowy) z „wolnym od blokad” (brak rywalizacji na mutex).
Rozwiązanie: W rzeczywistości, kanały w Go są chronione przez drobnoziarnisty mutex (hchan.lock) znajdujący się w strukturze nagłówka kanału. Przy wykonywaniu select, runtime zdobywa blokady wszystkich zaangażowanych kanałów — sortowanych według adresu pamięci, aby zapobiec deadlockom — aby atomowo sprawdzić ich stany bufora i kolejki oczekujących. Jeśli istnieje przypadek default i żaden kanał nie jest gotowy, runtime zwalnia te blokady i zwraca natychmiast, unikając parkowania goroutines. Niemniej jednak, przejęcie mutexa i tak ma miejsce, co oznacza, że operacja nie jest wolna od blokad. Przeciwnie, gdy wszystkie przypadki blokują, runtime parkuje goroutine, dodając strukturę sudog do kolejki oczekującej każdego kanału przed atomowym zwolnieniem wszystkich blokad i oddaniem procesora.
Firma zajmująca się wysokotonażowym handlem zbudowała agregator danych rynkowych, w którym centralny dispatcher używał select z default, aby ankietować wiele kanałów z feedami cenowymi, zakładając, że ten wzór zapewnia synchronizację o zerowym koszcie, odpowiednią dla wymagań opóźnień w mikrosekundach.
Opis problemu: Pod obciążeniem produkcyjnym, agregator wykazywał sporadyczne skoki opóźnienia przekraczające milisekundy. Profilowanie CPU ujawniło, że goroutine dispatcher spędzał 35% swoich cykli w runtime.lock i runtime.unlock, rywalizując o mutexy kanałów podczas inspekcji stanu. Zespół deweloperski błędnie utożsamiał „nienblokujące” z „wolnym od blokad”, prowadząc do użycia kanałów do ankietyzacji o wysokiej częstotliwości, zamiast do synchronizacji.
Różne rozważane rozwiązania:
Jedno z podejść zachowało strukturę select, ale zwiększyło rozmiary buforów kanałów do 1024 elementów, mając nadzieję, że zredukowane zostanie rywalizowanie. Chociaż to zmniejszyło blokowanie dla producentów, to nie zlikwidowało przejęcia mutexa wymaganego do wykonania sprawdzenia przypadku default, pozostawiając goroutine dispatcher nadal podatnym na ruch związany z koherencją pamięci z powodu blokad.
Inne rozwiązanie całkowicie zastąpiło ankietyzację kanałów wdrożeniem bezblokowym pierścienia buforowego, używając atomic.CompareAndSwapPointer. To wyeliminowało koszty związane z mutexami i zapewniło gwarancje postępu bez oczekiwania dla czytelników. Niemniej jednak, znacznie skomplikowało to kod, wymagając ręcznego zarządzania pamięcią i wprowadzając potencjalne problemy ABA, gdy producenci aktualizowali współdzielone wskaźniki.
Wybrane rozwiązanie używało sync/atomic Value do przechowywania niezmiennych struktur zrzutów danych rynkowych. Producenci atomowo zamieniali wskaźniki na nowe struktury, podczas gdy dispatcher wykonywał atomowe ładowania w swojej pętli. To zapewniło prawdziwe odczyty wolne od blokad z jedno-słowną atomowością, doskonale odpowiadającą semantyce „zwycięża ostatnia wartość” w danych o tickach finansowych.
Wynik: Modyfikacja zmniejszyła p99 opóźnienie dispatchera z 800 mikrosekund do 12 nanosekund, wyeliminowała wstrząsy planisty związane z mutexami i zmniejszyła całkowite wykorzystanie CPU o 42%, umożliwiając systemowi obsługę podwójnego throughputu na identycznym sprzęcie.
„Dlaczego runtime blokuje wszystkie kanały w selekcji jednocześnie i jaki konkretny protokół unikania deadlocków określa kolejność przejmowania blokad?”
Runtime Go sortuje przypadki selekcji według adresu pamięci ich podstawowych struktur hchan i zdobywa blokady w ściśle rosnącej kolejności adresów. Ta globalna całkowita kolejność zapobiega martwym pętlom, gdy dwa goroutines wykonują selekcje na pokrywających się zestawach kanałów. Jeśli goroutine A zablokuje kanał X, a następnie Y, podczas gdy goroutine B zablokuje Y, a następnie X, dochodzi do deadlocka; sortowanie na podstawie adresu zapewnia, że obie goroutines zawsze próbują najpierw zablokować X, a potem Y, eliminując okrężną zależność.
„Jak obecność przypadku default zmienia zachowanie bariery pamięci runtime w porównaniu z blokującym select?”
W blokującym selekcie bez default, goroutine musi opublikować swój węzeł oczekiwania (sudog) w kolejce oczekującej każdego kanału przed zaparkowaniem. Wymaga to bariery zapisu i ogrodzenia pamięci, aby zapewnić, że planista zauważy stan w kolejce przed tym, jak goroutine zostanie wstrzymana. Przy istniejącym przypadku default, goroutine nigdy nie parkuje; po prostu inspektuje stany pod blokadą i wraca natychmiast. W konsekwencji unika kosztów bariery pamięci związanych z publikowaniem węzłów oczekiwania i późniejszą unieważnieniem podręcznej pamięci po wznowieniu, chociaż nadal ponosi koszty synchronizacji właśnie blokad kanałów.
„W jakim konkretnym przypadku operacja wysyłania na buforowanym kanale z dostępną pojemnością nadal może nie zadziałać podczas instrukcji select?”
Dzieje się tak, gdy instrukcja select zawiera wiele przypadków odnoszących się do tego samego kanału lub gdy kanał jest jednocześnie zamykany. Konkretne, jeśli selekcja ocenia wiele przypadków wysyłania na identycznych kanałach, pseudo-losowy wybór runtime'a może wybrać inny przypadek, pozostawiając gotowe wysłanie niewykonane. Co bardziej krytyczne, jeśli inny goroutine zamyka kanał podczas fazy przejmowania blokad selekcji, oczekujące wysłanie wykryje zamknięcie, gdy blokady są utrzymywane i paniknie z komunikatem „wysyłanie na zamkniętym kanale”, co uniemożliwi normalne zakończenie operacji mimo wcześniejszej dostępnej pojemności.