Wstęp

Asynchroniczne wejście/wyjście (ang. asynchronous I/O) to technika zarządzania żądaniami wejścia/wyjścia w oderwaniu od wątków wykonywania.

Standardowe operacje wejścia/wyjścia, np. read() i write() powodują zablokowanie wątku wykonującego daną operację do czasu jej zakończenia. Powoduje to, że wątek może naraz zainicjować tylko jedno żądanie wejścia/wyjścia. W przypadku dostępu do zwykłych plików możliwe jest zniwelowanie tej wady poprzez zapamiętywanie przez system danych w pamięci podręcznej (zapisywanie z opóźnieniem dla operacji zapisu i czytanie z wyprzedzeniem dla operacji odczytu).

Są jednak zastosowania, w których proces chce mieć większą kontrolę nad wykorzystaniem pamięci podręcznej (np. bazy danych) lub dostęp do urządzenia nie jest buforowany (tzw. urządzenia surowe - ang. raw devices).

Użycie asynchronicznego wejścia/wyjścia umożliwia zarządzanie przez jeden wątek wieloma żądaniami wejścia wyjścia naraz i precyzyjną kontrolę nad rozpoczęciem i zakończeniem żądania wejścia/wyjścia.

Istnieje kilka standardów operacji asynchronicznego wejścia/wyjścia. W świecie Uniksa obowiązuje standard POSIX.1b i właśnie ten standard będzie dalej opisany.

Standard asynchronicznego wejścia/wyjścia POSIX.1b

Żądania wejścia/wyjścia

W standardowych operacjach wejścia/wyjścia wszystkie informacje opisujące żądanie wejścia/wyjścia zawarte są w parametrach wywoływanej funkcji systemowej. Ponieważ operacje asynchronicznego wejścia/wyjścia wykonują się w oderwaniu od wątku wykonywania, wszystkie parametry opisujące żądanie są zebrane w jednej strukturze aiocb. Wszystkie funkcje asynchronicznego wejścia/wyjścia operują właśnie na takich strukturach.

Najważniejsze pola struktury aiocb:

Rozpoczęcie operacji transmisji

Do rozpoczęcia operacji transmisji można użyć jednej z trzech funkcji:

int aio_read(struct aiocb *aiocbp)

Rozpoczyna operację odczytu opisywaną strukturą aiocbp. Pola aio_offset i aio_nbytes określają położenie i długość odczytywanego obszaru. Wczytane dane zostaną zapisane w buforze wskazywanym przez aio_buf.

Funkcja zwraca 0, jeśli żądanie zostało zarejestrowane. W przeciwnym wypadku funkcja zwraca -1 i ustawia odpowiednio zmienną errno.

int aio_write(struct aiocb *aiocbp)

Rozpoczyna operację zapisu opisywaną strukturą aiocbp. Pola aio_offset i aio_nbytes określają położenie i długość zapisywanego obszaru. Dane do zapisania znajdują się w buforze określonym przez aio_buf.

Funkcja zwraca 0, jeśli żądanie zostało zarejestrowane. W przeciwnym wypadku funkcja zwraca -1 i ustawia odpowiednio zmienną errno.

int lio_listio(int mode, struct aiocb *const list[], int nent, struct sigevent *sig)

Rozpoczyna operacje opisywane przez tablicę list wskaźników do struktur aiocb. Liczbę wskaźników określa parametr nent. Rodzaj operacji do wykonania wskazuje pole aio_lio_opcode w każdej ze struktur aiocb wskazywanych przez elementy tablicy. Możliwe wartości tego pola to:

Parametr mode określa czy operacja ma się wykonać w sposób blokujący, czy asynchronicznie. Może przybrać jedną z dwóch wartości:

Parametr sig określa sposób powiadamiania, który ma zostać użyty do powiadomienia o zakończeniu żądań (patrz Powiadamianie o zakończeniu operacji).

Funkcja zwraca 0, jeśli wszystkie żądania zostały zarejestrowane (lub pomyślnie zakończone, jeśli tryb wywołania był blokujący). W przeciwnym wypadku funkcja zwraca -1 i ustawia odpowiednio zmienną errno.

Powiadamianie o zakończeniu operacji

Sposób powiadomienia o zakończeniu operacji wejścia wyjścia określa struktura sigevent. Jest ona częścią struktury aiocb opisującej żądanie lub zostaje przekazana w wywołaniu funkcji lio_listio().

Sposób powiadamiania określa pole sigev_notify. Są następujące sposoby powiadamiania:

Sprawdzanie stanu operacji

Stan operacji można sprawdzić wywołując odpowiednią funkcję na jej strukturze aiocb.

int aio_error(const struct aiocb *aiocbp)

Sprawdza stan operacji. Może zwrócić:

ssize_t aio_return(const struct aiocb *aiocbp)

Pozwala sprawdzić liczbę odczytanych/zapisanych bajtów. Liczba ta ma analogiczne znaczenie jak liczba zwracana przez funkcje read() lub write().

Tej funkcji można użyć dopiero kiedy operacja się zakończy. Uwaga! Można ją wywołać tylko raz dla każdego zakończonego żądania transmisji.

Oczekiwanie na zakończenie operacji

int aio_suspend(const struct aiocb *const list[], int nent, const struct timespec *timeout)

Funkcja ta zawiesza wątek, który ją wykona do czasu aż któreś z żądań wskazywanych przez wskaźnik w tablicy list nie zostanie zakończone.

Pole nent określa liczbę wskaźników w tablicy. Pole timeout określa czas po jakim ma nastąpić powrót z funkcji, nawet jeśli żadne z żądań nie zostanie zakończone. Jeśli w polu timeout znajduje się NULL, oczekiwanie nie będzie przerwane do czasu zakończenia żądania (o ile nie zostanie dostarczony sygnał przerywający funkcję systemową).

Podanie wskaźnika NULL w polu tablicy list powoduje zignorowanie tego pola tablicy.

Funkcja zwraca 0, jeśli któreś z żądań zostało zakończone. W przeciwnym wypadku funkcja zwraca -1 i ustawia odpowiednio zmienną errno.

Dalsze informacje

Przedstawione funkcje służą do operowania asynchronicznym wejściem/wyjściem w podstawowym zakresie. Do innych użytecznych funkcji należą:

W strukturze aiocb znajduje się również nieomówione pole aio_reqprio, które służy do nadawania priorytetu żądaniom wejścia/wyjścia.

Dalsze szczegółowe informacje o standardzie AIO, dokładny opis parametrów i wartości zwracanych przez poszczególne funkcje można znaleźć w opisie standardu asynchronicznego wejścia/wyjścia POSIX.1b w bibliotece GLIBC.

Implementacja AIO w Linuksie

W celu pełnego korzystania z zalet AIO, jądro systemu musi wspierać asynchroniczne żądania wejścia/wyjścia. Jeśli takiego wsparcia nie ma, biblioteka GLIBC emuluje asynchroniczne wejście/wyjście za pomocą puli wątków wykonujących operacje wejścia/wyjścia. Parametry tej emulacji można ustawić za pomocą funkcji aio_init().

Standardowe jądro Linuksa w wersji 2.6.x zawiera obsługę asynchronicznego wejścia/wyjścia.

Dla wersji 2.4.x  istnieją łaty dodające takie wsparcie. 

Uwaga: niektóre operacje (np. dopisywanie na końcu pliku) i niektóre systemy plików, zwłaszcza we wcześniejszych wersjach serii 2.6 mogą powodować problemy, np. blokować wykonanie programu. W razie wątpliwości, najlepiej zbadać zachowanie programu w interesującym nas zastosowaniu.

Szczegóły techniczne

W celu korzystania z AIO należy dołączyć plik nagłówkowy aio.h i łączyć program wykonywalny z biblioteką librt.

Inne implementacje asynchronicznego wejścia/wyjścia

AIO nie jest jedynym ani najlepszym standardem asynchronicznego wejścia/wyjścia. Asynchroniczne wejście/wyjście jest np. częścią interfejsu Win32 używanego w systemach Windows NT/2000/XP.

Program przykładowy

Załączony program przykładowy aio-copy używa interfejsu AIO do wykonywania jednocześnie kilku kopii jednego pliku. Wszystkie operacje wejścia/wyjścia obsługiwane są przez jeden wątek główny.

Zasada działania

Stała NUM_ASYNC_IO określa ile może jednocześnie się wykonywać operacji odczytu. Z każdym żądaniem odczytu związane jest num_copies (liczba kopii) żądań zapisu - po jednym dla każdej kopii. Każde żądanie odczytu i jego żądania zapisu stanowią jedną przegródkę (ang. slot), niezależną od innych.

Na początku działania programu inicjowany jest odczyt NUM_ASYNC_IO pierwszych fragmentów pliku. Następnie wątek główny za pomocą funkcji aio_suspend() czeka na zakończenie którejś operacji wejścia/wyjścia.

Po zakończeniu operacji odczytu rozpoczynane są wszystkie operacje zapisu odczytanych danych do plików kopii.

Kiedy zakończy się operacja zapisu, sprawdzane jest, czy wszystkie operacje zapisu dla danej przegródki się zakończyły. Jeśli tak, przegródka jest znowu wolna, więc inicjowany jest odczyt kolejnego fragmentu pliku do tej przegródki i cykl rozpoczyna się od nowa.

Przedstawianie stanu żądań

Zdefiniowanie makra SHOW_IO_STATUS powoduje przedstawianie stanu żądań w momencie, gdy nastąpi zakończenie któregoś żądania. Stan każdego aktywnego żądania jest określony literą:

Zadania

Oto propozycje zadań do samodzielnego wykonania, uporządkowane według rosnącej trudności:
  1. Po zakończeniu operacji wejścia/wyjścia jest sprawdzane, czy operacja zakończyła się pomyślnie, ale nie jest sprawdzone, czy liczba przeczytanych/zapisanych bajtów jest zgodna z tym, co zostało zawarte w żądaniu (liczbę przeczytanych/zapisanych bajtów zwraca funkcja aio_return()). Należy dodać obsługę tego przypadku.
  2. Rozpoczęcie operacji zapisu do wszystkich kopii w przegródce (i początkowe odczytanie fragmentów pliku do przegródek) jest zrealizowane za pomocą kilku wywołań aio_write() (aio_read()). Należy to zrealizować za pomocą jednego wywołania funkcji lio_listio().
  3. Po zakończeniu wszystkich operacji zapisu nie można rozpocząć natychmiast kolejnych operacji zapisu w tej przegródce, ponieważ dane muszą zostać najpierw wczytane do bufora. Efektywniejszym rozwiązaniem jest wczytanie z wyprzedzeniem danych do innego bufora tak, by w momencie zakończenia zapisów można było się przełączyć na inny bufor i rozpocząć od razu operacje zapisu. Należy zaimplementować pulę buforów do czytania z wyprzedzeniem. Pula tych buforów ma być wspólna dla wszystkich przegródek, to znaczy że każda przegródka może wziąć gotowy bufor z puli. Jeśli nie ma wczytanego bufora w puli, należy wczytywać dane do bufora tak, jak to jest zrealizowane obecnie. Uwaga: nie wolno kopiować danych między pulą buforów do wczytywania z wyprzedzeniem a buforami używanymi w przegródkach - ma następować wymiana aktualnie używanego przez przegródkę bufora na bufor z wczytanymi danymi. Można założyć, że rozmiar puli buforów do czytania z wyprzedzeniem jest dany stałą NUM_READAHEAD_BUFFERS. Rozwiązanie zadania znajduje się w pliku aio-copy-readahead.c.

Autor: Krzysztof Lichota lichota@mimuw.edu.pl