Antipattern synchronních vstupně-výstupních operací

Blokování volajícího vlákna, zatímco se dokončují vstupně-výstupní operace, může snížit výkon a ovlivnit vertikální škálovatelnost.

Popis problému

Synchronní vstupně-výstupní operace blokuje volající vlákno, zatímco se vstupně-výstupní operace dokončuje. Volající vlákno přejde do stavu čekání a během tohoto období nemůže provádět užitečnou práci, čímž plýtvá prostředky pro zpracování.

Mezi běžné příklady vstupně-výstupních operací patří:

  • Načítání nebo ukládání dat do databáze nebo jakéhokoli typu trvalého úložiště.
  • Odesílání požadavku do webové služby.
  • Odesílání zprávy nebo načítání zprávy z fronty.
  • Zápis do místního souboru nebo čtení z něj.

Možné důvody vzniku tohoto antipatternu:

  • Zdá se, že se jedná o nejintuitivnější způsob provedení operace.
  • Aplikace od požadavku vyžaduje odpověď.
  • Aplikace používá knihovnu, která pro vstupně-výstupní operace poskytuje pouze synchronní metody.
  • Externí knihovna provádí synchronní vstupně-výstupní operace interně. Jediné volání synchronní vstupně-výstupní operace může zablokovat celý řetězec volání.

Následující kód nahraje soubor do Azure Blob Storage. Bloky kódu čekají na synchronní vstupně-výstupní operace na dvou místech – v metodě CreateIfNotExists a metodě UploadFromStream.

var blobClient = storageAccount.CreateCloudBlobClient();
var container = blobClient.GetContainerReference("uploadedfiles");

container.CreateIfNotExists();
var blockBlob = container.GetBlockBlobReference("myblob");

// Create or overwrite the "myblob" blob with contents from a local file.
using (var fileStream = File.OpenRead(HostingEnvironment.MapPath("~/FileToUpload.txt")))
{
    blockBlob.UploadFromStream(fileStream);
}

Tady je příklad čekání na odpověď z externí služby. Metoda GetUserProfile volá vzdálenou službu, která vrací UserProfile.

public interface IUserProfileService
{
    UserProfile GetUserProfile();
}

public class SyncController : ApiController
{
    private readonly IUserProfileService _userProfileService;

    public SyncController()
    {
        _userProfileService = new FakeUserProfileService();
    }

    // This is a synchronous method that calls the synchronous GetUserProfile method.
    public UserProfile GetUserProfile()
    {
        return _userProfileService.GetUserProfile();
    }
}

Kompletní kód pro oba tyto příklady najdete tady.

Jak problém vyřešit

Nahraďte synchronní vstupně-výstupní operace asynchronními operacemi. Uvolníte tím aktuální vlákno, které místo blokování může pokračovat v provádění užitečné práce, a pomůžete zlepšit využití výpočetních prostředků. Asynchronní provádění vstupně-výstupních operací je efektivní zejména pro zvládnutí neočekávaného prudkého zvýšení množství požadavků z klientských aplikací.

Řada knihoven poskytuje jak synchronní, tak asynchronní verze metod. Pokud je to možné, používejte vždy asynchronní verze. Tady je asynchronní verze předchozího příkladu nahrání souboru do Azure Blob Storage.

var blobClient = storageAccount.CreateCloudBlobClient();
var container = blobClient.GetContainerReference("uploadedfiles");

await container.CreateIfNotExistsAsync();

var blockBlob = container.GetBlockBlobReference("myblob");

// Create or overwrite the "myblob" blob with contents from a local file.
using (var fileStream = File.OpenRead(HostingEnvironment.MapPath("~/FileToUpload.txt")))
{
    await blockBlob.UploadFromStreamAsync(fileStream);
}

Operátor await vrátí řízení do volajícího prostředí, zatímco se provádí asynchronní operace. Kód následující po tomto příkazu funguje jako pokračování, které se spustí po dokončení asynchronní operace.

Dobře navržená služba by měla také poskytovat asynchronní operace. Tady je asynchronní verze webové služby, která vrací profily uživatelů. Metoda GetUserProfileAsync závisí na existenci asynchronní verze služby profilů uživatelů.

public interface IUserProfileService
{
    Task<UserProfile> GetUserProfileAsync();
}

public class AsyncController : ApiController
{
    private readonly IUserProfileService _userProfileService;

    public AsyncController()
    {
        _userProfileService = new FakeUserProfileService();
    }

    // This is a synchronous method that calls the Task based GetUserProfileAsync method.
    public Task<UserProfile> GetUserProfileAsync()
    {
        return _userProfileService.GetUserProfileAsync();
    }
}

Pro knihovny, které neposkytují asynchronní verze operací, můžete pro synchronní metody vytvořit asynchronní obálky. S tímto přístupem buďte opatrní. I když se tím může zrychlit odezva ve vlákně, které vyvolává asynchronní obálku, ve skutečnosti se využívá více prostředků. Může se vytvořit další vlákno a se synchronizací práce provedené tímto vláknem je spojená určitá režie. Některé kompromisy jsou popsané v tomto blogovém příspěvku: Měl/a bych zveřejnit asynchronní obálky pro synchronní metody?

Tady je příklad asynchronní obálky pro synchronní metodu.

// Asynchronous wrapper around synchronous library method
private async Task<int> LibraryIOOperationAsync()
{
    return await Task.Run(() => LibraryIOOperation());
}

Volající kód teď může čekat na obálku:

// Invoke the asynchronous wrapper using a task
await LibraryIOOperationAsync();

Důležité informace

  • Vstupně-výstupní operace, u kterých se předpokládá, že budou krátkodobé a pravděpodobně nezpůsobí kolizi, můžou být výkonnější jako synchronní operace. Příkladem může být čtení malých souborů na jednotce SSD. Režie související s odesláním úlohy do jiného vlákna a synchronizací s tímto vláknem po dokončení úlohy může převážit nad výhodami asynchronních vstupně-výstupních operací. Tyto případy jsou však poměrně vzácné a většina vstupně-výstupních operací by se měla provádět asynchronně.

  • Zlepšení výkonu vstupně-výstupních operací může způsobit, že se z jiných částí systému stanou kritické body. Například výsledkem odblokování vláken může být větší objem souběžných požadavků na sdílené prostředky, což zase vede k vyčerpání prostředků nebo omezování. Pokud se z toho stane problém, možná bude nutné škálovat webové servery nebo úložiště rozdělených dat na více instancí za účelem snížení množství kolizí.

Jak zjistit problém

Pro uživatele to může vypadat, že aplikace pravidelně nereaguje. Aplikace může selhat s výjimkami časového limitu. Můžou se také zobrazovat chyby HTTP 500 (Interní server). Na straně serveru se můžou blokovat příchozí požadavky klientů, dokud vlákno nebude k dispozici, a to způsobí nadměrnou délku fronty, což se projevuje chybami HTTP 503 (Služba není dostupná).

Následující postup vám pomůže identifikovat problém:

  1. Monitorujte produkční systém a určete, jestli blokovaná pracovní vlákna omezují propustnost.

  2. Pokud se kvůli nedostatku vláken blokují požadavky, zkontrolujte aplikaci a zjistěte, které operace možná provádějí vstupně-výstupní operace synchronně.

  3. Proveďte řízené zátěžové testování každé operace, která provádí synchronní vstupně-výstupní operace, a zjistěte, jestli tyto operace ovlivňují výkon systému.

Ukázková diagnostika

V následujících částech se tento postup použije u ukázkové aplikace popsané výše.

Monitorování výkonu webového serveru

V případě webových aplikací a webových rolí Azure je vhodné monitorovat výkon webového serveru služby IIS. Konkrétně věnujte pozornost délce fronty požadavků, abyste zjistili, jestli se požadavky během období vysoké aktivity blokují při čekání na dostupná vlákna. Tyto informace můžete získat tak, že povolíte diagnostiku Azure. Další informace naleznete v tématu:

Instrumentujte aplikaci, abyste viděli, jak se požadavky po přijetí zpracovávají. Trasování toku požadavku může pomoct identifikovat, jestli provádí pomalá volání a blokuje aktuální vlákno. Na blokované požadavky také může upozornit profilace vlákna.

Zátěžový test aplikace

Následující graf ukazuje výkon dříve popsané synchronní metody GetUserProfile s proměnlivým zatížením až 4 000 souběžných uživatelů. Aplikace je aplikací ASP.NET spuštěnou ve webové roli cloudové služby Azure.

Performance chart for the sample application performing synchronous I/O operations

V synchronní operaci je pevně zakódováno, aby pro účely simulace synchronní vstupně-výstupní operace přešla na 2 sekundy do režimu spánku, takže minimální doba odezvy je o něco delší než 2 sekundy. Když zatížení dosáhne přibližně 2 500 souběžných uživatelů, průměrná doba odezvy se dostane na stabilní hladinu, a to i přesto, že se objem požadavků za sekundu stále zvětšuje. Všimněte si, že pro tyto dvě míry se používá logaritmické měřítko. Počet požadavků za sekundu se od tohoto okamžiku do konce testu zdvojnásobí.

Když tento test posuzujeme izolovaně, nemusí být nutně jasné, jestli je synchronní vstupně-výstupní operace problém. Při větším zatížení může aplikace dosáhnout kritického bodu, kdy už webový server nedokáže včas zpracovávat požadavky, a klientská aplikace tak bude dostávat výjimky časového limitu.

Webový server služby IIS řadí příchozí požadavky do fronty, a ty se předávají do vlákna běžícího ve fondu vláken ASP.NET. Vzhledem k tomu, že každá operace provádí vstupně-výstupní operace synchronně, je vlákno blokované, dokud se operace nedokončí. S tím, jak se zvyšuje zatížení, nakonec budou přidělená a blokovaná všechna vlákna ASP.NET ve fondu vláken. Od tohoto okamžiku musí všechny další příchozí požadavky čekat ve frontě na dostupné vlákno. S rostoucí délkou fronty začíná u požadavků docházet k vypršení časového limitu.

Implementace řešení a ověření výsledku

Další graf ukazuje výsledek zátěžového testování asynchronní verze kódu.

Performance chart for the sample application performing asynchronous I/O operations

Propustnost je mnohem vyšší. Za stejnou dobu, jakou trval předchozí test, systém úspěšně zpracuje téměř desetinásobné zvýšení propustnosti naměřené v počtu požadavků za sekundu. Kromě toho je průměrná doba odezvy poměrně konstantní a drží se na přibližně 25krát menší hodnotě než u předchozího testu.