Asynchroniczne operacje I/O za pomocą IOCP Patrycja Brzozowska
I/O Completion Ports Porty zakończenia operacji I/O zapewniają wydajny model wątkowania do przetwarzania wielu asynchronicznych żądań I/O w systemie wieloprocesorowym. Gdy proces tworzy port zakończenia operacji I/O, system tworzy powiązany obiekt kolejki dla żądań, których jedynym celem jest obsługa tych żądań.
Jak działa IOCP ? Funkcja CreateIoCompletionPort tworzy port zakończenia I/O i kojarzy jeden lub więcej uchwytów pliku z tym portem. Gdy zakończy się asynchroniczna operacja I/O na jednym z tych uchwytów pliku, pakiet zakończenia I/O jest umieszczany w kolejce w kolejności FIFO (first-in-first-out) do powiązanego portu zakończenia operacji I/O.
Jednym z potężnych zastosowań tego mechanizmu jest połączenie punktu synchronizacji dla wielu uchwytów plików w jeden obiekt, chociaż istnieją również inne użyteczne aplikacje. Należy zwrócić uwagę, że podczas gdy pakiety są umieszczane w kolejce w kolejności FIFO, mogą one zostać z niej usunięte w innej kolejności.
Gdy uchwyt pliku jest powiązany z portem zakończenia, przekazany blok statusu nie zostanie zaktualizowany, dopóki pakiet nie zostanie usunięty z portu zakończenia. Jedynym wyjątkiem jest sytuacja, gdy pierwotna operacja powraca synchronicznie z błędem.
Wątek (utworzony przez główny wątek lub sam wątek główny) używa funkcji GetQueuedCompletionStatus, aby poczekać, aż pakiet zakończenia zostanie dodany do kolejki portu zakończenia operacji I/O, zamiast oczekiwać bezpośrednio na zakończenie asynchronicznych operacji I/O.
Wątki, które blokują ich wykonywanie na porcie zakończenia operacji I/O, są wydawane w kolejności „last-in-first-out" (LIFO), a następny pakiet ukończenia jest pobierany z kolejki FIFO portu zakończenia operacji I/O dla tego wątku. Oznacza to, że po wypuszczeniu pakietu ukończenia do wątku system zwalnia ostatni (najnowszy) wątek powiązany z tym portem, przekazując mu informację o ukończeniu najstarszego zakończenia I/O.
Mimo, że dowolna liczba wątków może wywołać funkcję GetQueuedCompletionStatus dla określonego portu zakończenia operacji I/O, gdy określony wątek wywoła GetQueuedCompletionStatus po raz pierwszy, zostanie powiązany z określonym portem zakończenia operacji I/O do momentu wystąpienia jednej z trzech rzeczy: wyjście wątku, wątek określa inny port zakończenia operacji I/O, wątek zamyka port zakończenia operacji I/O. Innymi słowy, pojedynczy wątek może być powiązany z najwyżej jednym portem zakończenia I/O.
Gdy pakiet zakończenia jest umieszczany w kolejce do portu zakończenia I/O, system najpierw sprawdza ile wątków powiązanych z tym portem jest uruchomionych. Jeśli liczba uruchomionych wątków jest mniejsza niż wartość współbieżności , to jeden z oczekujących wątków (najnowszy) może przetwarzać pakiet zakończenia. Gdy uruchomiony wątek zakończy przetwarzanie, zwykle wywołuje ponownie GetQueuedCompletionStatus, w którym to momencie albo zwraca z następnym pakietem ukończenia albo czeka (jeśli kolejka jest pusta).
Zalety IOCP Wszystkie operacje I/O mogą być zarejestrowane do tego samego portu zakończenia operacji I/O. Unikamy blokowania własnych wątków oraz wątków roboczych ThreadPool. Otrzymujemy automatyczne zarządzanie wątkami, które minimalizuje przełączanie kontekstów oraz daje głównemu wątkowi więcej czasu procesora.
Trochę kodu... Należy utworzyć port zakończenia operacji I/O oraz zachować do niego uchwyt. //create completion code var completionPortHandle = Interop.CreateIoCompletionPort(new IntPtr(-1), IntPtr.Zero, 0, 0);
Następnie po utworzeniu file handle dla asynchronicznej operacji I/O należy powiązać go do IOCP. const uint Flags = 128 | (uint)1 << 30; var fileHandle = Interop.CreateFile("test.txt", (uint)1 << 31, 0, IntPtr.Zero, 3, /*FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED */ Flags, IntPtr.Zero); Interop.CreateIoCompletionPort( fileHandle, completionPortHandle, (uint)fileHandle.ToInt64(), 0);
Wreszcie, za każdym razem wykonując operację należy podać wywołanie zwrotne oraz uzyskać wskaźnik do struktury NativeOverlapped*. var readBuffer = new byte[1024]; uint bytesRead; var overlapped = new Overlapped { AsyncResult = new FileReadAsyncResult() ReadCallback = (bytesCount, buffer) => var contentRead = Encoding.UTF8.GetString(buffer, 0, (int)bytesCount); }, Buffer = readBuffer } }; NativeOverlapped* nativeOverlapped = overlapped.UnsafePack(null, readBuffer); Interop.ReadFile(fileHandle, readBuffer, (uint)readBuffer.Length, out bytesRead, nativeOverlapped);
Jak wykonywane jest wywołanie zwrotne ? Poprzedni kod zapewnia tylko wywołanie zwrotne, ale nigdy go nie wywołuje. Jest to rola oddzielnego składnika, którego używamy do symulacji wątku portu zakończenia. var completionPortThread = new Thread(() => new IOCompletionWorker().Start(completionPortHandle)) { IsBackground = true }; completionPortThread.Start();
Ten komponent jest odpowiedzialny za sprawdzenie portu zakończenia dla elementów w kolejce i wywołanie powiązanego asynchronicznego wywołania zwrotnego (a także pewne czyszczenie). public class IOCompletionWorker { public unsafe void Start(IntPtr completionPort) while (true) uint bytesRead; uint completionKey; NativeOverlapped* nativeOverlapped; var result = Interop.GetQueuedCompletionStatus( completionPort, out bytesRead, out completionKey, &nativeOverlapped, uint.MaxValue); (...)
(...) var overlapped = Overlapped.Unpack(nativeOverlapped); if (result) { var asyncResult = ((FileReadAsyncResult)overlapped.AsyncResult); asyncResult.ReadCallback(bytesRead, asyncResult.Buffer); } else ThreadLogger.Log(Interop.GetLastError().ToString()); Overlapped.Free(nativeOverlapped);