APM Cancellation

A Task-ok felé vezető utat kikövezendő, nemrégiben szó volt a .NET 4 által bevezetett új Cancellation Model-ről. Az új model szép és jó, de az APM-es kódokon nem segít. Mit lehet tenni például, ha egy NetworkStream-en elindított aszinkron olvasási művelet beragad, mert a szerver nem akar válaszolni? A netet böngészve találhatunk áthidaló megoldást, például itt. Ha lezárjuk a szóban forgó file-t (ami lehet hálózati socket, soros port, stb), akkor az aszinkron művelet meghiúsul. Egy socketet azonban nem mindig praktikus lezárni. Lehet találni jobb megoldást?

Mit tud a Windows?

A Windows a “hagyományos”, nem Completion Port-ra épülő aszinkron műveletek esetére rendelkezik egy CancelIo() függvénnyel. Ez minden, a megadott file-ra vonatkozó, és a hívó szálon indított aszinkron műveletet leállít. Miért a hívó szálon? A sorozatindítóból emlékezhetünk, hogy eredeti felállásban az I/O Manager az I/O kéréseket leíró IRP-ket (I/O Request Packet) a műveletet indító szálhoz rendeli. Ez a szál fogja később átvenni a művelet eredményét, akár pollozgatással, akár egy Completion Routine segítségével, ami egy Asynchronous Procedure Call (APC) segítségével szintén a szóban forgó szálon fog meghívódni. Ha egy szál futása időközben véget ér, akkor az I/O Manager le is állítja a szál által kezdeményezett műveleteket. Az I/O műveletek és a szálak tehát hagyományos helyzetben eléggé egybe vannak forrva.

A .NET APM által használt, Completion Port-ra épülő I/O kérések azonban nincsenek az indító szálhoz rendelve, és nem is működik rajtuk a CancelIo() hívás. Ezen felül az I/O Manager nem szakítja meg az aszinkron műveletet, ha a kezdeményező szál futása véget ér. Ennek annyira nem is örülnénk, képzeljük el a következő helyzetet: terhelt Thread Pool miatt a Thread Pool sok szálat indított, és az egyik szálból indítunk egy APM-es kérést. A kérés még folyamatban van, közben a Thread Pool terheltsége is csökkent, emiatt az elkezdi megszüntetni a most már felesleges Thread Pool szálakat, köztük azt is, amin mi korábban egy aszinkron műveletet kezdeményeztünk. Ha most emiatt visszavonásra kerülne az indított művelet, az nem lenne helyes működés.

Ott tartunk tehát, hogy a Completion Port-ra kért aszinkron műveletek nem állnak le sem CancelIo() hívással, sem a kezdeményező szál leállításával. Milyen lehetőség van akkor? Az a furcsa helyzet, hogy bár a Completion Port-ok windows 2000-től rendelkezésre állnak, a Completion Port-on is működő CancelIoEx() WinAPI függvény csak a Windows Vista óta van jelen. Régebbi operációs rendszereken tehát nem lehet kulturált módon megszakítani egy Completion Port-ra irányított aszinkron műveletet. Ebből adódik, hogy a Vista előtt tényleg nem lehetett okosabbat kitalálni a file lezárásánál, így az APM tervezői sem csak szimplán kifelejtették az amúgy hasznos cancellation lehetőséget.

Mit tudnak az újabb Windows-ok?

Vistától kezdve tehát, közvetlenül a WinAPI-t használva, meg lehet szakítani egy I/O műveletet akkor is, ha az a Completion Port-ot használja. Ez persze nem teljesen igaz, ugyanis a műveletet nem a Windows, hanem az adott driver fogja megszakítani, ami vagy képes ezt megtenni, vagy nem. A lehetőségünk azonban megvan a megszakítási igényünk kifejezésére.

A szóban forgó függvény a következő:

BOOL WINAPI CancelIoEx(
  __in      HANDLE hFile,
  __in_opt  LPOVERLAPPED lpOverlapped
);

Mind a két paraméterrel találkoztunk már, de azért frissítsük fel az ismereteket. Az I/O Manager file-okban gondolkodik, akár merevlemezen található file-ról, akár soros portról, akár hálózati socketről van szó. Ezeket a file-okat használat előtt meg kell nyitni, ekkor az I/O Manager létrehoz egy File Object-et, aminek az azonosítója egy File Handle, ezt az azonosítót kapjuk meg a file megnyitásakor, és ezt várja a CancelIoEx első paraméterként.

A második, OVERLAPPED struktúra egy aszinkron művelet állapotát tárolja. Minden aszinkron művelethez egy saját OVERLAPPED struktúrát kell rendelni, ezt a programozó készíti el, és adja át az aszinkron művelet indításánál. Az APM-es cikkből kiderül, hogy a struktúra a mai követelmények már kevésbé felel meg, emiatt általában körbedekorálják más információkkal, és ezt teszi a .NET is. A második, OVERLAPPED paraméter nem kötelező, NULL érték esetén a függvényhívás az adott file összes aszinkron műveletét megszakítja.

CancelIoEx() .NET-ből

Az APM-ről szóló cikkben láthattuk, hogyan használja a .NET Framework belül a File Handle-t és az OVERLAPPED struktúrát. Ezek alapján látszik, hogy elég intim tudással kell bírni ahhoz, hogy a CancleIoEx() függvény teljes mértékben kihasználható legyen. Ezek miatt elsőre egy egyszerűbb példával kezdjük, és az OVERLAPPED struktúrát nem adjuk meg. Ekkor minden folyamatban levő aszinkron művelet megszakad.

Ahhoz, hogy a CancelIoEx() függvény használható legyen, a következő sor szükséges:

[DllImport("Kernel32.dll", SetLastError = true)]
extern static bool CancelIoEx(IntPtr hFile, IntPtr lpOverlapped);

Első lépésben indítunk egy mini szervert, amihez csatlakozni lehet:

var listener = new TcpListener(IPAddress.Any, 666);
listener.Start();

Ezután fogadni kell a bejövő kapcsolatokat. Csak egy kliens fog próbálkozni a példakódban, emiatt nem szükséges bonyolítani a kódot. Hogy mindent el tudjunk intézni a fő szálon, az Accept aszinkron párját használjuk. A BeginAcceptSocket-hez megadott Completion Callback nem fog mást csinálni, csak eltakarítja az IAsyncResult struktúrát egy EndAcceptSocket() hívással:

listener.BeginAcceptSocket(
  ar =>
  {
    listener.EndAcceptSocket(ar);
  },
  null);

Ezzel kész is van a szerver rész, és a fő szál is haladhat tovább, hogy futtassa a kliens kódját. A kliens első lépésként kapcsolódik a szerverhez, majd készít egy NetworkStream példányt a könnyebb kezelhetőség érdekében:

var client = new TcpClient();
client.Connect("localhost", 666);
var clientStream = client.GetStream();

A NetworkStream támogatja az APM-et, ezért indíthatunk rajta egy aszinkron olvasást. Az olvasási művelet 1024 byte-ot vár, a NetworkStream azonban megelégedne 1 rendelkezésre álló byte-tal is, ekkor már hívná a Completion Callback-et. A szerverünk viszont nem küld egy byte-ot sem. Emiatt az aszinkron kérés ott fog “várakozni” valahol az operációs rendszerben, ezt szeretnénk visszavonni:

var asyncResult =                     // Egy IAsyncResult az APM-nek megfelelően
        clientStream.BeginRead(       // Aszinkron olvasás indítása
        new byte[1024], 0, 1024,      // 1K buffer, 0 offsettől max 1K olvasása
        ar =>                         // Ha az aszinkron művelet kész,
        {
            try                       // Riportolja az olvasott byte-ok számát:
            {
                Console.WriteLine("1. received {0}", clientStream.EndRead(ar)); 
            }
            catch (IOException ex)    // Riportolja a hibát:
            {
                Console.WriteLine("1: " + ex.Message);
            }
        },
        null);                        // Nem utazik paraméter az asyncResult-ban

Miután elindítottuk az olvasást, a néma szerverünk miatt biztosak lehetünk benne, hogy nem tud olvasni semmit. Pár másodperc múlva így visszavonjuk a műveletet. Ehhez meg kell szerezni a File Handle-t, ami az I/O Manager számára azonosítja azt a File Object-et, amire vonatkozólag az aszinkron művelet elindult. Az olvasást egy NetworkStream példánnyal indítottunk, ami alatt egy Socket dolgozik. Ezt a Socketet közvetlenül nem érjük el (mivel az ezt visszaadó property protected), viszont a TcpClient példányon keresztül a Socket már megszerezhető. Ez a Socket viszont vissza tudja adni azt a File Handle-t, amire az I/O Managernek szüksége van:

Thread.Sleep(2000);                              // Egy kis idő visszavonás előtt.            
CancelIoEx(client.Client.Handle, IntPtr.Zero);   // Az írás visszavonása

A teljes kód itt található:

using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Threading;

namespace AsyncCancel
{
    class Program
    {
        [DllImport("Kernel32.dll", SetLastError = true)]
        extern static bool CancelIoEx(IntPtr hFile, IntPtr lpOverlapped);

        static void Main(string[] args)
        {
            // Egy mini szerver indítása.
            var listener = new TcpListener(IPAddress.Any, 666);
            listener.Start();

            // Egy bejövő kliens kiszolgálása. Az aszinkron hívás miatt a program futása
            // továbbhalad, emiatt külön szál indítása nélkül lehet a klienseket is futtatni.
            listener.BeginAcceptSocket(
              ar =>
              {
                  listener.EndAcceptSocket(ar);
              },
              null);

            // Egy kliens indítása
            TcpClient client = new TcpClient();
            client.Connect("localhost", 666);
            var clientStream = client.GetStream();

            // Az első kliens művelet, egy aszinkron olvasás. Mivel a szerver nem ír adatot,
            // a completion callback delegate nem fog meghívódni, csak hiba, vagy cancel esetén
            var asyncResult =
                  clientStream.BeginRead(
                    new byte[1024], 0, 1024,
                    ar =>
                    {
                        try
                        {
                            Console.WriteLine("1. received {0}", clientStream.EndRead(ar));
                        }
                        catch (IOException ex)
                        {
                            Console.WriteLine("1: " + ex.Message);
                        }
                    },
                    null);


            Thread.Sleep(2000);                              // Egy kis idő visszavonás előtt.            
            CancelIoEx(client.Client.Handle, IntPtr.Zero);   // Az írás visszavonása

            Console.ReadLine();
        } // Main()
    } // class Program
} // namespace AsyncCancel

A program futásának az eredménye pedig:

A kód működik, és olyan nagyot nem is kellett kalózkodni hozzá. A problémák akkor jönnek elő, ha olyan a protokolunk, hogy párhuzamos írás/olvasás történik. A CancelIoEx() ugyanis a fenti módon használva minden aszinkron műveletet visszavon. Egészítsük ki a példakódot egy aszinkron írással, hogy láthassuk a hatást.

Az aszinkron írásnál kicsit trükköznünk kell, hogy várakozási állapotba kerüljön. Hiába nem fogadja a byte-okat a szerverünk, az operációs rendszer (illetve a driver) az adatokat egy bizonyos határig pufferolja. Emiatt két írást indítunk, az első megtölti a puffert, így a második már nem fog lefutni:

// socket puffer teliírása. A completion callback szinte azonnal meghívódik majd, így ezt
// a műveletet nem lesz időnk visszavonni:
var asyncResult2 = 
      clientStream.BeginWrite(
        new byte[0x4500], 0, 0x4500,   // ez elég, hogy megtöltse a socket pufferét.
        ar =>
        {
          try
          {
            clientStream.EndWrite(ar);
            Console.WriteLine("sent 1");
          }
          catch (Exception ex)
          {
            Console.WriteLine("sent 1: " + ex.Message);
          }
        }, null);

// a puffer itt már betelt, ezt a műveletet nem tudja elvégezni a driver, emiatt várakoztatja:
var asyncResult3 = 
      clientStream.BeginWrite(
        new byte[256], 0, 256,
        ar =>
        {
          try
          {
            clientStream.EndWrite(ar);
            Console.WriteLine("sent 2");
          }
          catch (Exception ex)
          {
            Console.WriteLine("sent 2: " + ex.Message);
          }
        }, null);

Az eredeti példaprogramot a fenti kódrésszel kiegészítve lesz két várakozó művelet (egy olvasás és egy írás). Ebben az esetben a CancelIoEx() mind a kettőt leállítja, a futtatás eredményének a képernyője a következő:

Nem biztos, hogy ez a kívánt hatás. A műveletek specifikus visszavonásához viszont szükség van az OVERLAPPED struktúrára. Az OVERLAPPED struktúra használata a .NET Framework implementációs belügye, emiatt a következő kódrészek elég csúnyák lesznek, használatuk kevéssé javasolt. Elképzelhető, hogy egy következő verzióban a belső implementációs változtatások miatt már nem is működnek.

Az APM-ről szóló cikkben láttuk, hogy a Framework hogyan használja karöltve a saját IAsyncResult implementációját és a régi OVERLAPPED struktúrát. Azt is tudjuk, hogy a .NET Framework egy Overlapped osztályon keresztül állítja elő a natív OVERLAPPED struktúrát. Erre a natív OVERLAPPED struktúrára van most szükség.

Minden .NET osztálynak egy saját IAsyncResult implementációja van. Most mi socketekkel foglalkozunk, a Socket osztályra épülő mechanizmusok pedig a System.Net.Sockets névtér OverlappedAsyncResult implementációt használják. A fenti kódban a ClientStream.BeginRead() hívás tehát egy OverlappedAsyncResult példányt ad vissza, ebből kell előkeresni az OVERLAPPED struktúrát.

Mivel az OVERLAPPED struktúra az operációs rendszernek lesz átadva, ez a struktúra ki van “tűzve” (pinnelve) a memóriában, nehogy a garbage collector átmozgassa a területet, amire az operációs rendszer nem számít. Ezek után a terület címét egy IntPtr-ben láthatjuk, ezt kell átadni a WinAPI-s függvényeknek is.

Az OverlappedAsyncResult felépítése az alábbi ábrán látható:

Az a szerencse ért minket, hogy az OVERLAPPED struktúra címe kényelmesen kinyerhető az OverlappedHandle property olvasásával. Ami kevéssé szerencsés, hogy ez a property internal láthatóságú, emiatt csak reflection segítségével olvasható. A kód amire szükségünk van, a következő:

object o = asyncResult3.GetType().
             GetProperty("OverlappedHandle", BindingFlags.NonPublic | BindingFlags.Instance).
               GetValue(asyncResult3, null);

Amikor ez megvan, akkor a kapott érték segítségével adott művelet visszavonható a többi folyamatban levő művelet megszakítása nélkül:

CancelIoEx(client.Client.Handle, (IntPtr)o);

Konklúzió:

Az APM tervezésekor még nem állt eszköz rendelkezésre a Completion Port-ot használó aszinkron műveletek visszavonására, emiatt a .NET Framework nem tartalmaz erre szolgáló funkciókat. Az újabb operációs rendszerek esetében a visszavonás elvileg lehetséges, gyakorlatban viszont olyan csúnya, belső implementációs részleteket kihasználó megoldásokkal kell élni, aminek használata nem javasolt.

Reméljük, hogy a következő Framework verziókban az APM-et támogató osztályok kiegészülnek ezekkel a funkciókkal, hiszen láthatjuk, a feladat ma már megoldható.

  1. Leave a comment

Leave a comment