Többszálú alkalmazások esetén elkerülhetetlen, hogy két szál adatot osszon meg egymással. Ebben mondhatni semmi különleges nincs, kellő körültekintéssel és megfelelő zárolással ez egy C# programon belül minden gond nélkül kivitelezhető. De mi van akkor, ha nem szálak között, hanem folyamatok között szeretnénk adatot megosztani?
Természetesen erre is van lehetÅ‘ség. Ezt nevezzük interprocess communication-nek, vagy röviden IPC-nek. Ahhoz, hogy folyamatok között kommunikáljunk, csak az operációs rendszer által biztosÃtott lehetÅ‘ségeket használhatjuk, mivel az operációs rendszer feladata a különbözÅ‘ folyamatok egymástól elszigetelése.
Éppen ezért operációs rendszerenként eltérő, hogy milyen IPC megoldások támogatottak. Azonban az elmúlt 20-30 évben ezek többsége szabványosodott. Ettől függetlenül azért vannak olyan IPC megoldások, amelyek nagyon platformspecifikusak. Ilyen például Windows alatt a grafikus alkalmazások számára elérhető message API, vagy Linux alatt a Signal rendszer.
Az IPC megoldások igencsak szerteágazóak. Éppen ezért nem minden megoldás támogatott mindenhol. Ezért igyekeztem olyan megoldásokat bemutatni, amelyek C# és .NET alól operációs rendszertől függetlenül használhatóak.
Az IPC megoldások bemutatásához készÃtettem két interfészt, amit a kliensek és szerverek fognak használni. Ezeket egy közös szerelvénybe helyeztem el, ami az Ipc.Common nevet kapta. Jelen fejezet példi esetén a kliens program lesz az, amelyik üzeneteket küld, a szerver pedig fogadja ezeket.
A kliens interfész:
namespace Ipc.Common
{
internal interface IClient
{
public void Send(string data);
}
}
A szerver interfész:
using System;
namespace Ipc.Common
{
internal interface IServer : IDisposable
{
void Start();
event EventHandler<ClientDataEventArgs> DataRecieved;
}
}
A szerver implementálja az IDisposable felületet. Ennek oka az, hogy az IPC megoldások operációs rendszer függÅ‘ nem natÃv erÅ‘források és ezek determinisztikus felszabadÃtásáról nekünk kell gondoskodnunk. A felület Start metódusa felelÅ‘s a szerver elindÃtásért. A DataRecieved esemény pedig akkor fog ellövÅ‘dni, ha a szerver adatot kapott. Ez egy ClientDataEventArgs tÃpust vár, ami az EventArgs tÃpust bÅ‘vÃti tovább egy string tÃpusú Data tulajdonsággal.
using System;
namespace Ipc.Common
{
[Serializable]
public sealed class ClientDataEventArgs : EventArgs
{
public ClientDataEventArgs(string data)
{
Data = data;
}
public string Data { get; }
}
}
Megosztott fájlok
A legegyszerűbb IPC kommunikációs megoldás két folyamat között, ha azok fájlokon keresztül beszélgetnek. Egyik folyamat létrehozza a fájlt úgy, hogy más folyamatok gond nélkül olvasni tudják. A másik folyamat pedig a fájlt olvassa és feldolgozza a benne található információkat.
Előnyök
- Egyszerű felépÃtés. A megvalósÃtáshoz szükséges információkat a fájlkezelés fejezetben megtanultuk
- Bármelyik operációs rendszeren megvalósÃtható
Hátrányok
- Lemezen létrejön a fájl, ami a lemez I/O-t használja, ami lehet nagyon lassú is
- Ha SSD-re Ãrunk viszonylag sokszor, akkor az élettartalmát rövidÃtjük
Socket alapú kommunikáció
A hálózati protokollok (TCP/IP és UDP) lehetővé teszik számunkra, hogy akár több gépen vagy akár egy gépen belül futó alkalmazásokat kapcsoljunk össze. Ez az IP protokoll implementációjának köszönhető.
using System;
using System.Net.Sockets;
using System.Text;
namespace Ipc.Common
{
public class SocketClient : IClient
{
public void Send(string data)
{
using (var client = new UdpClient())
{
try
{
client.Connect(string.Empty, 63000);
var dataBytes = Encoding.UTF8.GetBytes(data);
client.Send(dataBytes, dataBytes.Length);
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
}
}
}
}
Egy gépen belüli és hálózati kommunikáció esetén érdemes az UDP-re támaszkodni, ha nem kell megerÅ‘sÃtés arról, hogy a cél program megkapta az üzenetet. Jelen esetben az UdpClient a helyi gépre (localhost) csatlakozik, méghozzá a 63000-es porton és elküldi oda az üzenetét.
A fogadó oldal picivel bonyolultabb, de abszolút nem vészes:
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
namespace Ipc.Common
{
public sealed class SocketServer : IServer
{
private UdpClient _server;
public event EventHandler<ClientDataEventArgs> DataRecieved;
public SocketServer()
{
_server = new UdpClient(63000);
}
public void Dispose()
{
if (_server != null)
{
_server.Close();
_server.Dispose();
_server = null;
}
}
public void Start()
{
Task.Factory.StartNew(() =>
{
var ip = new IPEndPoint(IPAddress.Any, 0);
while (true)
{
var bytes = _server.Receive(ref ip);
var data = Encoding.UTF8.GetString(bytes);
if (DataRecieved != null)
{
DataRecieved?.Invoke(this, new ClientDataEventArgs(data));
}
}
});
}
}
}
Előnyök
- Operációs rendszer független
- A kommunikáció titkosÃtható
- Akár több gép összekapcsolására is alkalmas
- Számos protokoll használható adatátvitelre, nem feltétlen kell sajátot feltalálnunk.
Hátrányok
- Operációs rendszer függően lehet, hogy tűzfal szabályokkal kell megküzdenünk, hogy működjön
- A titkosÃtás biztonságos implementálása akár nehéz feladat is lehet.
Osztott memória – Shared memory
Az osztott memória koncepciója létezik Unix és Windows rendszereken egyaránt. A lényege, hogy az operációs rendszer segÃtségével kijelölünk egy olyan memóriaterületet, amit a csatlakozó programok egyaránt Ãrni és olvasni tudnak. Ez a koncepció olyan, mint ha egy megosztott változót definiálnánk két szál kommunikációjára.
A .NET keretrendszer erre a célra a Memory Mapped File koncepciót alkalmazza, ami lényegében egy, a memóriában létező fájlt definiál, ami opcionálisan rendelkezhet lemezen tárolt részekkel is. Nézzük is meg a kliensünket:
using System.IO.MemoryMappedFiles;
using System.Text;
using System.Threading;
namespace Ipc.Common
{
public class MemFileClient : IClient
{
public void Send(string data)
{
if (EventWaitHandle.TryOpenExisting("mmf", out EventWaitHandle handle) == false)
{
handle = new EventWaitHandle(false, EventResetMode.AutoReset, "mmf");
}
using (handle)
using (var file = MemoryMappedFile.CreateOrOpen("memFile", 1024))
using (MemoryMappedViewAccessor view = file.CreateViewAccessor())
{
var bytes = Encoding.Default.GetBytes(data);
view.WriteArray(0, bytes, 0, bytes.Length);
handle.Set();
}
}
}
}
A kliens egy EventWaitHandle segÃtségével szinkronizál a másik folyamattal. Az EventWaitHandle kifejezetten két folyamat közötti információ jelzésre szolgál1. Ennek egy egyedi nevet kell adnunk, ami a gépen belül egyedi lesz.
Ezt követÅ‘en a MemoryMappedFile.CreateOrOpen hÃvással létrehozzuk a fájlunkat. Mivel ez nem rendelkezik lemezre mutató elérési útvonallal, ezért ez csak a memóriában fog létezni. A második paramétere, az 1024 a méretét adja meg byte-ban.
Ezt követÅ‘en létre kell hoznunk egy MemoryMappedViewAccessor tÃpusú nézetet. Ennek az oka az, hogy egy MemoryMappedFile akár több gigabyte adat megosztására is alkalmas, illetve megoldható vele, hogy több folyamat is Ãrja ugyanazt a memóriát, amÃg eltérÅ‘ szegmensekben dolgoznak. Ezt követÅ‘en a WriteArray metódussal Ãrni tudjuk a létrehozott nézetünkben az adatot.
A szerver oldalon pedig a következő kód kezeli le az üzeneteket:
using System;
using System.IO.MemoryMappedFiles;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Ipc.Common
{
public sealed class MemFileServer : IServer
{
public event EventHandler<ClientDataEventArgs> DataRecieved;
private ManualResetEvent kill = new ManualResetEvent(false);
public void Dispose()
{
kill.Set();
kill.Dispose();
}
public void Start()
{
Task.Factory.StartNew(() =>
{
var evt = null as EventWaitHandle;
if (EventWaitHandle.TryOpenExisting("mmf", out EventWaitHandle handle) == false)
{
evt = new EventWaitHandle(false, EventResetMode.AutoReset, "mmf");
}
using (evt)
using (var file = MemoryMappedFile.CreateOrOpen("memFile", 1024))
using (var view = file.CreateViewAccessor())
{
var data = new byte[1024];
while (WaitHandle.WaitAny(new WaitHandle[] { kill, evt }) == 1)
{
view.ReadArray(0, data, 0, data.Length);
int count = 0;
for (int i=0; i<data.Length; i++)
{
if (data[i] == 0)
break;
++count;
}
var str = Encoding.Default.GetString(data, 0, count);
DataRecieved?.Invoke(this, new ClientDataEventArgs(str));
}
}
});
}
}
}
A kódban a kill egy ManualResetEvent tÃpusú szál szikronizációs esemény2, amit a Dispose metódusban lövünk el, hogy a külön szálon futó memória figyelésünk megszakadjon. A szerver szintén a MemoryMappedFile.CreateOrOpen és CreateViewAccessor hÃvásokkal létrehozza vagy megnyitja a memory mapped fájlunkat és a hozzá tartozó nézetet. Ezt követÅ‘en egy ciklusban folyamatosan vizsgáljuk, hogy egy kliens jelzett-e. Ha igen, akkor kiolvassuk a memória tartalmát.
Ez egy picivel komplikáltabb, mint amire számÃtunk, mivel a ReadArray a teljes tömböt (ami most 1024 byte) kiolvassa. Ha az üzenetünk nem pontosan 1024 karakterbÅ‘l áll, akkor szöveggé alakÃtás végén egy adag \0 karaktert is kapunk ajándékba, amire nincs szükségünk. Éppen ezért a count változó fogja tartalmazni a ténylegesen megkapott byte-ok számát. A pontos érték meghatározásáért a for ciklus felelÅ‘s.
Előnyök
- Nagy mennyiségű információ is átvihető
- Bináris átvitel
- Több alkalmazás is Ãrhatja a memóriaterületet
Hátrányok
- Szinkronizálás és értesÃtés nem biztosÃtott. Ezt nekünk kell megoldanunk
- Jelenleg csak Windows esetén támogatott ez a megoldás (.NET 6), habár a Linux is rendelkezik osztott memóriatámogatással
Pipe
A pipe-ok a Unix-szerű rendszerek és egy jó ideje a Windows szerves részét képezik. SegÃtségükkel két parancssoros programot tudunk úgy összekapcsolni, hogy az egyik program kimenetét átirányÃtjuk a másik bemenetére. A parancsértelmezÅ‘ben erre a | jel alkalmazható.
Ez egy egyirányú kommunikáció, de lehetÅ‘ségünk van speciális, úgynevezett Named pipe-ok létrehozására is. Ezek segÃtségével nem csak kettÅ‘ programot tudunk összekapcsolni. A .NET Named pipe implementációja Stream alapú, vagyis a használatában a fájlkezelés esetén tanultak után nem lesz sok újdonság. A kliens kódja:
using System.IO;
using System.IO.Pipes;
namespace Ipc.Common
{
public sealed class NamedPipeClient : IClient
{
public void Send(string data)
{
using (var client = new NamedPipeClientStream(".", "namedpipe", PipeDirection.Out))
{
client.Connect();
using (var writer = new StreamWriter(client))
{
writer.WriteLine(data);
}
}
}
}
}
A NamedPipeClientStream valósÃtja meg a pipe-hoz csatlakozást és az adatok elérését. Az elsÅ‘ paramétere a számÃtógép nevét határozza meg. Jelen esetben ez egy . karakter, ami a helyi gépet jelenti. A második paraméter a pipe neve, a harmadik paraméter pedig a pipe iránya. Ez lehet In, Out vagy InOut.
A számÃtógépnév azért fontos, mert ennek segÃtségével is akár több gépen átÃvelÅ‘ adatmegosztás hozható létre. A szerver kódja:
using System;
using System.IO;
using System.IO.Pipes;
using System.Threading.Tasks;
namespace Ipc.Common
{
public sealed class NamedPipeServer : IServer
{
public event EventHandler<ClientDataEventArgs> DataRecieved;
private NamedPipeServerStream _server;
public NamedPipeServer()
{
_server = new NamedPipeServerStream("namedpipe", PipeDirection.In);
}
public void Dispose()
{
_server.Disconnect();
_server.Dispose();
}
public void Start()
{
Task.Factory.StartNew(() =>
{
while (true)
{
_server.WaitForConnection();
using (var reader = new StreamReader(_server))
{
var msg = reader.ReadToEnd();
DataRecieved?.Invoke(this, new ClientDataEventArgs(msg));
}
}
});
}
}
}
A szerver esetén a NamedPipeServerStream osztályt kell használnunk a pipe létrehozásához. A konstruktorban itt csak a pipe nevét kell megadni és az irányát. A Start metódusban látható, hogy a WaitForConnection() blokkoló hÃvás hatására csak akkor folytatódik a program futása, ha egy kliens csatlakozott a pipe-hoz. Ezután ugyanúgy, a stream API segÃtségével megszerezzük az adatot, amit továbbÃtunk eseményen keresztül.
Előnyök
- Aszinkron API is elérhető
- Operációs rendszertől függetlenül működik
- Egyszerű használni.
- A
SetAccessControlmetódussal, ami egyPipeSecurity3 osztályt vár, felhasználói szinten korlátozni tudjuk a hozzáférést.
Hátrányok
- Nagy mennyiségű adat átvitelére lassú tud lenni
Tesztelő programok
A tesztelÅ‘ programok közül a szervert kell hamarább elindÃtani, ami majd várja a kliens kapcsolatokat. A szerver kódja:
using Ipc.Common;
using System;
namespace Ipc.Server
{
public static class Program
{
public static void Main(string[] args)
{
Console.WriteLine("Server running. Start client");
Console.WriteLine("Press a key to exit app");
var socketSrv = new SocketServer();
socketSrv.Start();
socketSrv.DataRecieved += OnDataRecieve;
var memFileSrv = new MemFileServer();
memFileSrv.Start();
memFileSrv.DataRecieved += OnDataRecieve;
var pipeSrv = new NamedPipeServer();
pipeSrv.Start();
pipeSrv.DataRecieved += OnDataRecieve;
Console.ReadKey();
socketSrv.Dispose();
memFileSrv.Dispose();
}
private static void OnDataRecieve(object sender, ClientDataEventArgs e)
{
if (!string.IsNullOrEmpty(e.Data))
Console.WriteLine($"{sender.GetType().Name}: {e.Data}");
}
}
}
Kliens:
using Ipc.Common;
namespace Ipc.Client
{
public static class Program
{
public static void Main(string[] args)
{
var socketClient = new SocketClient();
socketClient.Send("Hello from Sockets");
var memClient = new MemFileClient();
memClient.Send("Hello from Memory Mapped Files");
var pipeClient = new NamedPipeClient();
pipeClient.Send("Hello from Named pipe");
}
}
}
A kliens futtatása után a szerver ablak kimenete:
Server running. Start client
Press a key to exit app
SocketServer: Hello from Sockets
MemFileServer: Hello from Memory Mapped Files
NamedPipeServer: Hello from Named pipe