A C# Framework for Interprocess Synchronization and Communication

本文介绍了一个用于C#的简单自包含框架,旨在提供跨线程和进程的同步及通信机制。该框架利用了多种结构如信号量、邮箱、内存映射文件等,并通过示例展示了如何实现进程间消息传递。

A C# Framework for Interprocess Synchronization and Communication

By  Christoph Ruegg8 Aug 2004
 
 Prize winner in Competition "C# Jul 2004"
 
 

0. Introduction

Microsoft has provided the .NET framework various helpful thread synchronization primitives starting from monitors up to reader-writer locks. What is missing is the same on the inter-process level, as well as simple message passing mechanisms useful e.g. for service (client/server or SOA) and producer/consumer patterns on both thread and process level. I try to fill this gap with a simple self-contained framework for both inter-thread and inter-process synchronization and communication (IPC) using structures like semaphores, mailboxes, memory-mapped files, blocking channels and simple message flow controllers. The set of classes provided in this articles is also available as a library project (open source, BSD License) maintained on www.cdrnet.net/projects/threadmsg/.

The ambition of this framework is as short as simple:

  1. Abstraction: A message processing thread should not need to matter whether its messages are sent to the next thread or to another machine through a MSMQ message queue.
  2. Simplicity: Passing messages to other processes with a single method call.

Note: I removed all the XML Comments of the code samples in the article to save space - check out the attached source code if you need more details about the methods and their arguments.

1. Starting with a Sample

To demonstrate how simple inter-process message passing could be, I will start with a small sample: A console application that can be started as either a reader or a writer, depending on the command line arguments. In a writer process you may enter some text and send it to a mailbox (return key), a reader displays all messages received from the mailbox. You may start as many writers and readers as you want, but every message will only be shown on exactly one reader.

[Serializable]
struct Message
{
  public string Text;
}

class Test
{
  IMailBox mail;

  public Test()
  {
    mail = new ProcessMailBox("TMProcessTest",1024);
  }

  public void RunWriter()
  {
    Console.WriteLine("Writer started");
    Message msg;
    while(true)
    {
      msg.Text = Console.ReadLine();
      if(msg.Text.Equals("exit"))
        break;
      mail.Content = msg;
    }
  }

  public void RunReader()
  {
    Console.WriteLine("Reader started");
    while(true)
    {
      Message msg = (Message)mail.Content;
      Console.WriteLine(msg.Text);
    }
  }

  [STAThread]
  static void Main(string[] args)
  {
    Test test = new Test();
    if(args.Length > 0)
      test.RunWriter();
    else
      test.RunReader();
  }
}

Once a mailbox (here: ProcessMailBox) is created using the constructor, receiving a message is as easy as getting the Content property, sending as easy as setting it. The getter blocks the thread if no data is available, and the setter blocks if there's already some content available not requested by the reader. Thanks to this blocking, the application is completely interrupt-driven and does not stress the CPU in any way (no polling or spinning). Any type of messages/objects is allowed as long as they are decorated with the SerializableAttribute.

However, what happens behind the scenes is a bit more complex: The messages are transferred through one of the only remaining ways of sharing memory between processes: memory mapped files (MMF), in our case virtual files existing only in the system page file. Access to this file is synchronized using two Win32 semaphores. The messages are binary serialized written to the file, that's why the SerializableAttribute is needed. Both MMF and Win32 Semaphores require direct NT Kernel Syscalls, but no unsafe code is needed thanks to the handy Marshal class provided by the .NET Framework. More details are discussed later in this article.

2. Inter-Thread and Inter-Process Synchronization in the .NET World

Communication between threads and processes requires either shared memory or a built-in mechanism for transferring data into and out of the process/thread. In the case of shared memory, there is also a set of synchronization primitives needed to allow concurrent access.

All threads in a single process share a common logical address space (the heap), but starting from Windows 2000 there's no way to share memory between processes. However, processes are allowed to read and write to the same file and the WinAPI provides various syscalls to simplify mapping files to the process' address space and to work with virtual files existing only as kernel objects ("sections") pointing to a memory block in the system page file. For both inter-thread shared heaps and inter-process shared files, concurrent access may result in data inconsistency. We discuss in short several mechanisms to ensure the orderly execution of cooperating processes or threads allowing data consistency to be maintained.

2.1 Thread Synchronization

The .NET Framework and C# offer very simple and straightforward thread synchronization mechanisms usingMonitors and the lock-statement (we will not discuss the .NET Framework's Mutex Type in this article). Using lock is the recommended way to go for thread synchronization, no matter what other primitives this article provides.

void Work1()
{
  NonCriticalSection1();
  Monitor.Enter(this);
  try
  {
    CriticalSection();
  }
  finally
  {
    Monitor.Exit(this);
  }
  NonCriticalSection2();
}
void Work2()
{
  NonCriticalSection1();
  lock(this)
  {
    CriticalSection();
  }
  NonCriticalSection2();
}

Both Work1 and Work2 are equivalent. In C#, the second example is favorable because it is shorter and less error-prone.

2.2 Inter-Thread Semaphores

One of the classic synchronization primitives (introduced by Edsger Dijkstra) is the counting semaphore. Semaphores are objects with a counter and two operations: Acquire (also called 'P' or 'Wait') and Release (also called 'V' or 'Signal'). A semaphore decrements the counter and blocks (optional timeout) on Acquire if the counter is zero (before decrementing), while it increments the counter on Release without blocking. Although semaphores are simple structures, they are somewhat tricky to implement. Fortunately, the blocking behavior of the built-in Monitor helps.

public sealed class ThreadSemaphore : ISemaphore
{
  private int counter;
  private readonly int max;

  public ThreadSemaphore() : this(0, int.Max) {}
  public ThreadSemaphore(int initial) : this(initial, int.Max) {}
  public ThreadSemaphore(int initial, int max)
  {
    this.counter = Math.Min(initial,max);
    this.max = max;
  }

  public void Acquire()
  {
    lock(this)
    {
      counter--;
      if(counter < 0 && !Monitor.Wait(this))
        throw new SemaphoreFailedException();
    }
  }

  public void Acquire(TimeSpan timeout)
  {
    lock(this)
    {
      counter--;
      if(counter < 0 && !Monitor.Wait(this,timeout))
        throw new SemaphoreFailedException();
    }
  }

  public void Release()
  {
    lock(this)
    {
      if(counter >= max)
        throw new SemaphoreFailedException();
      if(counter < 0)
        Monitor.Pulse(this);
      counter++;
    }
  }
}

Semaphores are useful for more complex blocking scenarios like channels we will discuss later. You could also use semaphores for mutual exclusion locking of critical sections (Work3, see below), but it's highly recommended to use the built-in lock-keyword instead as demonstrated above in Work2.

Please note that counting semaphores are potentially dangerous objects if not used carefully. You are on the safe side however if you follow a basic rule: Never call Release if Acquire fails, but call it if it succeeds whatever happens after the call. The finally statement in Work3 is one way to enforce this rule, but note that the Acquire call has to be outside of the try statement as Release must not be called if Acquire fails.

ThreadSemaphore s = new ThreadSemaphore(1);
void Work3()
{
  NonCriticalSection1();
  s.Acquire();
  try
  {
    CriticalSection();
  }
  finally
  {
    s.Release();
  }
  NonCriticalSection2();
}

2.3 Inter-Process Semaphores

To synchronize resource access between processes we need primitives like the ones discussed above for the process level. Unfortunately, there is no process level Monitor class available in the .NET Framework. However, the Win32 Api provides Semaphore Kernel Objects that may be used to synchronize access between processes. Robin Galloway-Lunn introduces how to map Win32 semaphores to the .NET world in "Using Win32 Semaphores in C#". Our implementation looks similar:

[DllImport("kernel32",EntryPoint="CreateSemaphore",
     SetLastError=true,CharSet=CharSet.Unicode)]
internal static extern uint CreateSemaphore(
  SecurityAttributes auth, int initialCount,
    int maximumCount, string name);

[DllImport("kernel32",EntryPoint="WaitForSingleObject",
 SetLastError=true,CharSet=CharSet.Unicode)]
internal static extern uint WaitForSingleObject(
 uint hHandle, uint dwMilliseconds);

[DllImport("kernel32",EntryPoint="ReleaseSemaphore",
 SetLastError=true,CharSet=CharSet.Unicode)]
[return : MarshalAs( UnmanagedType.VariantBool )]
internal static extern bool ReleaseSemaphore(
  uint hHandle, int lReleaseCount, out int lpPreviousCount);
    
[DllImport("kernel32",EntryPoint="CloseHandle",SetLastError=true,
  CharSet=CharSet.Unicode)]
[return : MarshalAs( UnmanagedType.VariantBool )]
internal static extern bool CloseHandle(uint hHandle);
public class ProcessSemaphore : ISemaphore, IDisposable
{
  private uint handle;
  private readonly uint interruptReactionTime;

  public ProcessSemaphore(string name) : this(
   name,0,int.MaxValue,500) {}
  public ProcessSemaphore(string name, int initial) : this(
   name,initial,int.MaxValue,500) {}
  public ProcessSemaphore(string name, int initial,
   int max, int interruptReactionTime)
  {       
    this.interruptReactionTime = (uint)interruptReactionTime;
    this.handle = NTKernel.CreateSemaphore(null, initial, max, name);
    if(handle == 0)
      throw new SemaphoreFailedException();
  }

  public void Acquire()
  {
    while(true)
    { //looped 0.5s timeout to make NT-blocked threads interruptable.
      uint res = NTKernel.WaitForSingleObject(handle, 
       interruptReactionTime);
      try {System.Threading.Thread.Sleep(0);} 
      catch(System.Threading.ThreadInterruptedException e)
      {
        if(res == 0)
        { //Rollback 
          int previousCount;
          NTKernel.ReleaseSemaphore(handle,1,out previousCount);
        }
        throw e;
      }
      if(res == 0)
        return;
      if(res != 258)
        throw new SemaphoreFailedException();
    }
  }

  public void Acquire(TimeSpan timeout)
  {
    uint milliseconds = (uint)timeout.TotalMilliseconds;
    if(NTKernel.WaitForSingleObject(handle, milliseconds) != 0)
      throw new SemaphoreFailedException();  
  }

  public void Release()
  {
    int previousCount;
    if(!NTKernel.ReleaseSemaphore(handle, 1, out previousCount))
      throw new SemaphoreFailedException();  
  }

  #region IDisposable Member
  public void Dispose()
  {
    if(handle != 0)
    {
      if(NTKernel.CloseHandle(handle))
        handle = 0;
    }
  }
  #endregion
}

The important point is that the semaphore is named. This allows other processes to create a handle to the same semaphore just by entering the same name. To make blocked threads interruptible we use a (dirty) workaround using timeouts and Sleep(0). We need interrupt support to safely shutdown the threads. It's recommended however to release the semaphore until no more thread is blocked, allowing a clean application exit.

You may also have noticed that both the inter-thread and the inter-process semaphore share the same interface. This pattern is achieved on all classes, leading to the abstraction mentioned in the introduction. Note however that for performance reasons you should NOT use inter-process implementations for inter-thread scenarios or inter-thread implementations for single thread scenarios.

3. Inter-Process Shared Memory: Memory Mapped Files

We have seen how to synchronize access to shared resources for both threads and processes. What is missing for transferring messages is the shared resource itself. For threads this is just as easy as declaring a class member variable, but for processes we need a technique called Memory Mapped Files (MMF) provided by the Win32 API. Working with MMF is not much harder than working with the Win32 Semaphores discussed above. What we need first is a handle of such a mapped file using the CreateFileMapping Syscall:

[DllImport("Kernel32.dll",EntryPoint="CreateFileMapping",
     SetLastError=true,CharSet=CharSet.Unicode)]
internal static extern IntPtr CreateFileMapping(uint hFile, 
 SecurityAttributes lpAttributes, uint flProtect,
  uint dwMaximumSizeHigh, uint dwMaximumSizeLow, string lpName);
    
[DllImport("Kernel32.dll",EntryPoint="MapViewOfFile",
 SetLastError=true,CharSet=CharSet.Unicode)]
internal static extern IntPtr MapViewOfFile(IntPtr hFileMappingObject, 
  uint dwDesiredAccess, uint dwFileOffsetHigh,
  uint dwFileOffsetLow, uint dwNumberOfBytesToMap);
    
[DllImport("Kernel32.dll",EntryPoint="UnmapViewOfFile",
 SetLastError=true,CharSet=CharSet.Unicode)]
[return : MarshalAs( UnmanagedType.VariantBool )]
internal static extern bool UnmapViewOfFile(IntPtr lpBaseAddress);
public static MemoryMappedFile CreateFile(string name, 
     FileAccess access, int size)
{
  if(size < 0)
    throw new ArgumentException("Size must not be negative","size");

  IntPtr fileMapping = NTKernel.CreateFileMapping(0xFFFFFFFFu,null,
   (uint)access,0,(uint)size,name);
  if(fileMapping == IntPtr.Zero)
    throw new MemoryMappingFailedException();

  return new MemoryMappedFile(fileMapping,size,access);
}

We prefer virtual files directly in the system page file, so we provide -1 (0xFFFFFFFF) as the file handle to create our mapped file handle. We also specify the required file size in bytes and a name to allow other processes to access the same file concurrently. Having such a file, we may map several parts (specified by offset and size in bytes) of this file to our local address space. We do this with the MapViewOfFile Syscall:

public MemoryMappedFileView CreateView(int offset, int size,
      MemoryMappedFileView.ViewAccess access)
{
  if(this.access == FileAccess.ReadOnly && access == 
    MemoryMappedFileView.ViewAccess.ReadWrite)
    throw new ArgumentException(
     "Only read access to views allowed on files without write access",
     "access");
  if(offset < 0)
    throw new ArgumentException("Offset must not be negative","size");
  if(size < 0)
    throw new ArgumentException("Size must not be negative","size");
  IntPtr mappedView = NTKernel.MapViewOfFile(fileMapping,
   (uint)access,0,(uint)offset,(uint)size);
  return new MemoryMappedFileView(mappedView,size,access);
}

In unsafe code, we could just take the returned pointer (mappedView) and cast it to our data structures. Nevertheless, as we do not want unsafe code we use the Marshal class to read and write bytes and integers to it. The offset parameter is used to specify where to start reading or writing to the mapped file, relative to the view offset.

public byte ReadByte(int offset)
{
  return Marshal.ReadByte(mappedView,offset);
}
public void WriteByte(byte data, int offset)
{
  Marshal.WriteByte(mappedView,offset,data);
}

public int ReadInt32(int offset)
{
  return Marshal.ReadInt32(mappedView,offset);
}
public void WriteInt32(int data, int offset)
{
  Marshal.WriteInt32(mappedView,offset,data);
}
public void ReadBytes(byte[] data, int offset)
{
  for(int i=0;i<data.Length;i++)
    data[i] = Marshal.ReadByte(mappedView,offset+i);
}
public void WriteBytes(byte[] data, int offset)
{
  for(int i=0;i<data.Length;i++)
    Marshal.WriteByte(mappedView,offset+i,data[i]);
}

However, we want to write and read whole object trees to the file, so we need more advanced accessors with automatic binary serialization support:

public object ReadDeserialize(int offset, int length)
{
  byte[] binaryData = new byte[length];
  ReadBytes(binaryData,offset);
  System.Runtime.Serialization.Formatters.Binary.BinaryFormatter formatter
    = new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter();
  System.IO.MemoryStream ms = new System.IO.MemoryStream(
   binaryData,0,length,true,true);
  object data = formatter.Deserialize(ms);
  ms.Close();
  return data;
}
public void WriteSerialize(object data, int offset, int length)
{
  System.Runtime.Serialization.Formatters.Binary.BinaryFormatter formatter
    = new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter();
  byte[] binaryData = new byte[length];
  System.IO.MemoryStream ms = new System.IO.MemoryStream(
   binaryData,0,length,true,true);
  formatter.Serialize(ms,data);
  ms.Flush();
  ms.Close();
  WriteBytes(binaryData,offset);
}

Please note that serialized size of the object should not exceed the view size. The serialized size is always bigger than the size of the object itself. I have not tried binding the memory stream directly to the mapped view instead of the byte array but that should work too, probably even with a small performance gain.

4. Mailbox: Passing Messages between Threads and Processes

A mailbox has nothing to do with neither email nor NT Mailslots. It is a safe shared memory structure that can hold only one object. The content is read and written through a property. If the mailbox does not hold an object, a thread reading the content is blocked until another thread writes some content. If it already holds content, a thread trying to write to it is blocked until another thread reads the content first. The content can only be read once - its reference is automatically removed after reading. We've developed above all we need to build such mailboxes.

4.1 Inter-Thread Mailbox

A mailbox is very easy to build using two semaphores: one is signaled when the box is empty, the other when it is full. To read from the mailbox one first waits until the mailbox is full and signals the empty semaphore after reading. To write one needs to wait until it's empty and signals the full semaphore after writing. Note that the empty semaphore is signaled at the beginning.

public sealed class ThreadMailBox : IMailBox
{
  private object content;
  private ThreadSemaphore empty, full;

  public ThreadMailBox()
  {
    empty = new ThreadSemaphore(1,1);
    full = new ThreadSemaphore(0,1);
  }

  public object Content
  {
    get
    {
      full.Acquire();
      object item = content;
      empty.Release();
      return item;
    }
    set 
    {
      empty.Acquire();
      content = value;
      full.Release();
    }
  }
}

4.2 Inter-Process Mailbox

The inter-process version is nearly as simple as the inter-thread implementation. The only difference is that we now use inter-process semaphores and that we read and write to a memory mapped file instead of a simple class member variable. As serialization could fail, we provide a small rollback exception handler to undo any changes made to the mailbox state. There are many possible error sources (invalid handles, access denied, file size,SerializableAttribute missing ...).

public sealed class ProcessMailBox : IMailBox, IDisposable
{
  private MemoryMappedFile file;
  private MemoryMappedFileView view;
  private ProcessSemaphore empty, full;

  public ProcessMailBox(string name,int size)
  {
    empty = new ProcessSemaphore(name+".EmptySemaphore.MailBox",1,1);
    full = new ProcessSemaphore(name+".FullSemaphore.MailBox",0,1);
    file = MemoryMappedFile.CreateFile(name+".MemoryMappedFile.MailBox",
      MemoryMappedFile.FileAccess.ReadWrite,size);
    view = file.CreateView(0,size,
     MemoryMappedFileView.ViewAccess.ReadWrite);
  }

  public object Content
  {
    get
    {
      full.Acquire();
      object item;
      try {item = view.ReadDeserialize();}
      catch(Exception e)
      {  //Rollback
        full.Release();
        throw e;
      }
      empty.Release();
      return item;
    }

    set 
    {
      empty.Acquire();
      try {view.WriteSerialize(value);}
      catch(Exception e)
      {  //Rollback
        empty.Release();
        throw e;
      }
      full.Release();
    }
  }

  #region IDisposable Member
  public void Dispose()
  {
    view.Dispose();
    file.Dispose();
    empty.Dispose();
    full.Dispose();
  }
  #endregion
}

Now we have all the tools needed for the IPC message-passing sample in the beginning of the article. You may want to scroll back to the sample as it demonstrates how the ProcessMailBox could be used.

5. Channels: Queued Message Transfer

An important point of mailboxes is that they can hold only one object at once. If a worker in a long processing chain (connected with mailboxes) needs a bit more time than usual for a special command, the whole chain is blocked immediately. Often it's more favorable to have buffered message-passing channels where you can pick out incoming message whenever you've got time left without blocking the sender. Such buffering is provided by channels, an alternative to the simpler mailboxes. Again, we will discuss both an inter-thread and an inter-process implementation.

5.1 Reliability

Another important difference between mailboxes and channels is that channels have some reliability features and for example automatically dump messages failed to send to the queue (because of a thread interrupt while waiting for a lock) to an internal dump container. This means that channel-processing threads can safely be shutdown without loosing any messages. This is maintained by two abstract classes, ThreadReliability and ProcessReliability - every channel implementation derives from one of them.

5.2 Inter-Thread Channel

The inter-thread channel is based on the mailbox but uses a synchronized queue instead of a variable as a message buffer. Thanks to the counting semaphore model the channel blocks receiving if the queue is empty and blocks sending if the queue is full. You cannot run into any enqueue/dequeue failures. We achieve this by initializing the empty semaphore with the channel size and the full semaphore with zero. If a thread sending a message is interrupted while being blocked in the empty semaphore we copy the message to the dump container and let the exception propagate. No dumping is required in the receive method as you won't loose any message when being interrupted there. Note that a thread can only be interrupted while being blocked, that is when calling Aquire() on a semaphore.

public sealed class ThreadChannel : ThreadReliability, IChannel
{
  private Queue queue;
  private ThreadSemaphore empty, full;

  public ThreadChannel(int size)
  {
    queue = Queue.Synchronized(new Queue(size));
    empty = new ThreadSemaphore(size,size);
    full = new ThreadSemaphore(0,size);
  }

  public void Send(object item)
  {
    try {empty.Acquire();}
    catch(System.Threading.ThreadInterruptedException e)
    {
      DumpItem(item);
      throw e;
    }
    queue.Enqueue(item);
    full.Release();
  }

  public void Send(object item, TimeSpan timeout)
  {
    try {empty.Acquire(timeout);}
    ...
  }

  public object Receive()
  {
    full.Acquire();
    object item = queue.Dequeue();
    empty.Release();
    return item;
  }

  public object Receive(TimeSpan timeout)
  {
    full.Acquire(timeout);
    ...
  }
  
  protected override void DumpStructure()
  {
    lock(queue.SyncRoot)
    {
      foreach(object item in queue)
        DumpItem(item);
      queue.Clear();
    }
  }
}

5.3 Inter-Process Channel

Building an inter-process channel is a bit harder as you first need a way to provide a buffer. Possible solutions could be to use an inter-process mailbox and queue the send or receive methods depending on the required behavior. To avoid several drawbacks of this solution we'll implement a queue directly in the memory-mapped file instead. A first class,MemoryMappedArray, splits the file in several pieces accessible using an index. A second class, MemoryMappedQueue, builds a classic ring buffer around this array (see attached source code for more details). To allow both direct byte/integer access and binary serialization, one first calls Enqueue/Dequeue and then uses the write/read methods as needed (the queue puts them automatically to the right place). Both Array and Queue implementations are neither thread- nor process-safe so we have to control mutual exclusive access using a mutex-emulating inter-process semaphore (we could also have wrapped a Win32 Mutex). Beside of these two classes, the channel implementation is mostly the same as the inter-process mailbox. Again we have to take care of thread interrupts in the Send() methods as well as possible serialization failures.

public sealed class ProcessChannel : ProcessReliability, IChannel, IDisposable
{
  private MemoryMappedFile file;
  private MemoryMappedFileView view;
  private MemoryMappedQueue queue;
  private ProcessSemaphore empty, full, mutex;

  public ProcessChannel( int size, string name, int maxBytesPerEntry)
  {
    int fileSize = 64+size*maxBytesPerEntry;

    empty = new ProcessSemaphore(name+".EmptySemaphore.Channel",size,size);
    full = new ProcessSemaphore(name+".FullSemaphore.Channel",0,size);
    mutex = new ProcessSemaphore(name+".MutexSemaphore.Channel",1,1);
    file = MemoryMappedFile.CreateFile(name+".MemoryMappedFile.Channel",
      MemoryMappedFile.FileAccess.ReadWrite,fileSize);
    view = file.CreateView(0,fileSize,
     MemoryMappedFileView.ViewAccess.ReadWrite);
    queue = new MemoryMappedQueue(view,size,maxBytesPerEntry,true,0);
    if(queue.Length < size || queue.BytesPerEntry < maxBytesPerEntry)
      throw new MemoryMappedArrayFailedException();
  }

  public void Send(object item)
  {
    try {empty.Acquire();}
    catch(System.Threading.ThreadInterruptedException e)
    {
      DumpItemSynchronized(item);
      throw e;
    }
    try {mutex.Acquire();}
    catch(System.Threading.ThreadInterruptedException e)
    {
      DumpItemSynchronized(item);
      empty.Release();
      throw e;
    }
    queue.Enqueue();
    try {queue.WriteSerialize(item,0);}
    catch(Exception e)
    {
      queue.RollbackEnqueue();
      mutex.Release();
      empty.Release();
      throw e;
    }
    mutex.Release();
    full.Release();
  }

  public void Send(object item, TimeSpan timeout)
  {
    try {empty.Acquire(timeout);}
    ...
  }

  public object Receive()
  {
    full.Acquire();
    mutex.Acquire();
    object item;
    queue.Dequeue();
    try {item = queue.ReadDeserialize(0);}
    catch(Exception e)
    {
      queue.RollbackDequeue();
      mutex.Release();
      full.Release();
      throw e;
    }
    mutex.Release();
    empty.Release();
    return item;
  }

  public object Receive(TimeSpan timeout)
  {
    full.Acquire(timeout);
    ...
  }
  
  protected override void DumpStructure()
  {
    mutex.Acquire();
    byte[][] dmp = queue.DumpClearAll();
    for(int i=0;i<dmp.Length;i++)
      DumpItemSynchronized(dmp[i]);
    mutex.Release();
  }

  #region IDisposable Member
  public void Dispose()
  {
    view.Dispose();
    file.Dispose();
    empty.Dispose();
    full.Dispose();
    mutex.Dispose();
  }
  #endregion
}

6. Message Routing

We've discussed how to synchronize resource access and how to pass messages between threads and processes using mailboxes or channels. When working with blocking channels, you may run into problems e.g. when you need to listen to more than one channel in the same thread. To solve such situations, there are some small class modules available: A channel forwarder, a multiplexer and demultiplexer and a channel event gateway. You may define your own channel processors the same way using the simple IRunnable pattern provided by the two abstract classes SingleRunnableand MultiRunnable (check out the attached source code for more details).

6.1 Channel Forwarder

A channel forwarder does nothing more but listening on a channel and forwarding received messages to another channel. If needed, the forwarder may put each received message into an envelope marked with a constant number before forwarding (this feature is used in the multiplexer, see below).

public class ChannelForwarder : SingleRunnable
{
  private IChannel source, target;
  private readonly int envelope;

  public ChannelForwarder(IChannel source, 
   IChannel target, bool autoStart, bool waitOnStop)
    : base(true,autoStart,waitOnStop)
  {
    this.source = source;
    this.target = target;
    this.envelope = -1;
  }
  public ChannelForwarder(IChannel source, IChannel target, 
   int envelope, bool autoStart, bool waitOnStop)
    : base(true,autoStart,waitOnStop)
  {
    this.source = source;
    this.target = target;
    this.envelope = envelope;
  } 

  protected override void Run()
  {  //NOTE: IChannel.Send is interrupt save and 
     //automatically dumps the argument. 
    if(envelope == -1)
      while(running)
        target.Send(source.Receive());
    else
    {
      MessageEnvelope env;
      env.ID = envelope;
      while(running)
      {
        env.Message = source.Receive();
        target.Send(env);
      }
    }
  }
}

6.2 Channel Multiplexer and Demultiplexer

A multiplexer listens on several input channels and forwards every received message (in an envelope to identify the input channel) to a common output channel. This may be used to listen to multiple channels at once. A demultiplexer on the other hand listens on a common input channel and forwards them to one of several output channels depending on the message envelope.

public class ChannelMultiplexer : MultiRunnable
{
  private ChannelForwarder[] forwarders;

  public ChannelMultiplexer(IChannel[] channels, int[] ids, 
    IChannel output, bool autoStart, bool waitOnStop)
  {
    int count = channels.Length;
    if(count != ids.Length)
      throw new ArgumentException("Channel and ID count mismatch.","ids");

    forwarders = new ChannelForwarder[count];
    for(int i=0;i<count;i++)
      forwarders[i] = new ChannelForwarder(channels[i],
       output,ids[i],autoStart,waitOnStop);

    SetRunnables((SingleRunnable[])forwarders);
  }
}
public class ChannelDemultiplexer : SingleRunnable
{
  private HybridDictionary dictionary;
  private IChannel input;

  public ChannelDemultiplexer(IChannel[] channels, int[] ids, 
    IChannel input, bool autoStart, bool waitOnStop)
    : base(true,autoStart,waitOnStop)
  {
    this.input = input;

    int count = channels.Length;
    if(count != ids.Length)
      throw new ArgumentException("Channel and ID count mismatch.","ids");

    dictionary = new HybridDictionary(count,true);
    for(int i=0;i<count;i++)
      dictionary.add(ids[i],channels[i]);
  }

  protected override void Run()
  {  //NOTE: IChannel.Send is interrupt save and 
     //automatically dumps the argument.
    while(running)
    {
      MessageEnvelope env = (MessageEnvelope)input.Receive();
      IChannel channel = (IChannel)dictionary[env.ID];
      channel.send(env.Message);
    }
  }
}

6.3 Channel Event Gateway

The channel event gateway receives messages from a channel and fires an event for each message received. This class may be useful for event-oriented applications like GUIs, or to initialize minor activities using the system ThreadPool. Remember however that in case of WinForms you can't access any UI controls directly from the event handler but have to use Invoke() instead, as the handler is executed in the gateway thread, not in the UI thread.

public class ChannelEventGateway : SingleRunnable
{
  private IChannel source;
  public event MessageReceivedEventHandler MessageReceived;

  public ChannelEventGateway(IChannel source, bool autoStart,
   bool waitOnStop) : base(true,autoStart,waitOnStop)
  {
    this.source = source;
  }
  
  protected override void Run()
  {
    while(running)
    {
      object c = source.Receive();
      MessageReceivedEventHandler handler = MessageReceived;
      if(handler != null)
        handler(this,new MessageReceivedEventArgs(c));
    }
  }
}

7. The Pizza Drive-in Demo

That's it; we have discussed the most important structures and techniques of the framework (others classes like the Rendezvous and Barrier implementations are ignored in this article). We end this article the same way we began it: with a demonstration. This time we have a look at a small pizza drive-in simulation. The screen shot at the top of this page shows this simulation in action: four parallel processes talking to each other. The diagram below shows how data/messages flow between the four processes using inter-process channels and inside of the processes using faster inter-thread channels and mailboxes.

To set the ball rolling, a customer orders a pizza and something to drink. He does this with a method call in the customer interface that posts an Order message to the CustomerOrders channel. The order taker, always waiting for new orders, posts two cook instructions (one for the pizza and one for the drink) to the CookInstruction channel. The same time he forwards the order to the cashier using the CashierOrder queue. The cashier asks the pricing server for an adequate price and sends an Invoice to the customer in hope of a fast Payment reply. In the meantime, the cooks will have noticed the cook instructions and sent the finished items to the packer. The packer waits until an order is complete and forwards the packet to the customer.

To run this demo, open 4 command shells (cmd.exe) and start as many cooks as you want using "PizzaDemo.exe cook", the backend using "PizzaDemo.exe backend" and the facade process with the customer interface using "PizzaDemo.exe facade" (replace 'PizzaDemo' with the name of your assembly). Note that some threads (like the cooks) sleep some seconds each time to increase reality. Press return to stop and exit a process. If you press enter while still in action you'll see that some messages are dumped in the dump report at the end. In a real world application, the dump container would be stored to disk.

The demo uses several mechanisms introduced in this article. For example, the cashier loads a ChannelMultiplexerto listen to both customer payments and orders and uses two mailboxes for the pricing service. The shipment gateway is a ChannelEventGateway, allowing the customer to be notified with an event as soon as a food package is ready. All the processes should also work as Windows NT Services and on Terminal Server installations.

8. Conclusion

We've discussed how service oriented architecture could be built and how inter-process communication and synchronization could be implemented in C#. However, this is not the only solution for such problems. For example, using that many threads could be a serious problem in bigger projects. Completely missing are transaction support and alternative channel/mailbox implementations like named pipes and TCP sockets. There may also be some flaws in the architecture, please let me know.

9. References

10. History

  • July 28, 2004: Initial Article
  • August 04, 2004: ThreadSemaphore now allows checking for illegal releases. If such a release is detected an exception is thrown that usually indicates a serious (but sometimes hardly noticeable) programming error. All structures in the article now use checking. I also added a small comment in section 2.2 to point out correct semaphore handling and updated both downloads.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here

About the Author

转载于:https://www.cnblogs.com/yanzhenan/archive/2013/05/28/3103825.html

<think>首先,用户的问题是:“Understanding Interprocess Communication (IPC) mechanisms in operating systems”,意思是“理解操作系统中的进程间通信(IPC)机制”。我需要根据系统级指令来构建回答。 系统级指令: 1. 所有行内数学表达式使用 $...$ 格式。 2. 独立公式使用 $$...$$ 格式并单独成段。 3. LaTeX语法正确。 4. 使用中文回答。 5. 生成相关问题。 6. 回答中引用的段落末尾自然地添加引用标识,例如 [^1]。 用户可见层: - 回答结构清晰,帮助用户逐步解决问题。 - 尽量保证回答真实可靠。 - 参考站内引用,但用户提供了引用:[^1], [^2], [^3], [^4],这些是关于IPC的引用,我需要自然地融入回答中。 引用内容: - [^1]: 关于IPC的定义和目的。 - [^2]: 关于微内核的定义。 - [^3]: 关于RPC(远程过程调用)的描述。 - [^4]: RPC如何使IPC像调用函数一样简单。 用户的问题是关于IPC机制的,所以我需要解释什么是IPC、为什么需要它、常见的IPC机制类型等。 回答结构: 1. **介绍IPC**:定义IPC并解释其重要性。 2. **IPC机制类型**:列出并描述常见的IPC机制,如管道、消息队列、共享内存、信号量、套接字、RPC等。 3. **详细描述**:对每个机制进行简要解释。 4. **引用融入**:自然地添加引用标识。 5. **生成相关问题**:在最后添加相关问题部分。 使用中文回答,所以整个回答用中文。 确保LaTeX正确:如果有数学表达式,比如在描述算法时,但IPC机制可能不需要太多数学,除非是性能分析。例如,在讨论共享内存时,可能涉及地址空间,但保持简单。指令说“所有行内数学表达式”,但IPC主题可能不涉及太多数学,所以如果没有必要,就不强行添加。 步骤: 1. **开头**:解释IPC是什么。 2. **主体**:逐步介绍各种机制。 - 管道(Pipes) - 消息队列(Message Queues) - 共享内存(Shared Memory) - 信号量(Semaphores) - 套接字(Sockets) - RPC(Remote Procedure Call) 3. **结尾**:总结重要性。 4. **相关问题**:生成3-5个相关问题。 融入引用: - 例如,在定义IPC时,引用[^1]。 - 在讨论RPC时,引用[^3]和[^4]。 - 在提到微内核时,引用[^2],但可能不直接相关,除非IPC与内核相关。 引用[^2]是关于微内核的,可能与IPC相关,因为微内核依赖于IPC进行模块间通信。 自然地添加引用标识,比如在句子末尾:[^1] 生成相关问题: - 类似示例中的问题,如“IPC的性能如何?”、“IPC在分布式系统中的应用?”等。 最终输出格式: - 回答部分。 - §§相关问题§§ 部分。 确保回答真实可靠:基于一般知识,IPC是操作系统标准主题。</think>### 理解操作系统中的进程间通信(IPC)机制 进程间通信(Interprocess Communication, IPC)是操作系统中用于不同进程之间交换数据和协调行为的关键机制。它允许独立的进程(如应用程序或系统服务)共享信息、同步操作,并协作完成任务,从而支持多任务处理和分布式计算[^1]。在操作系统中,IPC机制的设计直接影响系统性能、可靠性和安全性。下面我将逐步解释IPC的基本概念、常见机制及其工作原理。 #### 1. **IPC的基本概念和重要性** - **为什么需要IPC?** 在操作系统中,每个进程拥有独立的地址空间,以防止相互干扰。但许多任务(如数据传输或资源共享)需要进程间协作。IPC提供了标准化的方式来实现这种协作,避免数据冲突并提升效率。例如,在微内核架构中,IPC是核心机制,用于模块间通信[^2]。 - **关键目标**:IPC机制需确保数据一致性、低延迟和高吞吐量。常见挑战包括死锁避免和同步问题,例如使用信号量来管理资源访问。 #### 2. **常见IPC机制类型** IPC机制可分为两类:**本地IPC**(同一计算机上的进程间通信)和**远程IPC**(跨网络通信)。以下是主要机制的解释: - **管道(Pipes)** 管道是最简单的IPC形式,用于单向数据流。它基于文件描述符,允许一个进程的输出成为另一个进程的输入。例如,在Unix/Linux中,命令行工具通过管道连接: ``` $ command1 | command2 # command1的输出作为command2的输入 ``` 管道适用于父子进程间通信,但只能用于半双工通信(即数据只能单向流动)。性能上,数据传递通常高效,但容量有限。 - **消息队列(Message Queues)** 消息队列允许进程通过发送和接收消息来通信,支持异步操作。消息被存储在队列中,进程可以独立读取。例如,POSIX消息队列使用`mq_send`和`mq_receive`函数。 优点:支持多对多通信和优先级消息,但队列管理可能引入额外开销。在Windows系统中,这类似于消息传递机制[^1]。 - **共享内存(Shared Memory)** 共享内存允许多个进程访问同一块物理内存区域,是最快的IPC机制,因为它避免了数据复制。例如,在Linux中,使用`shmget`和`shmat`系统调用。 缺点:需要同步机制(如信号量)来防止数据竞争。共享内存常用于高性能场景,如数据库系统。 - **信号量(Semaphores)** 信号量用于进程同步,而不是数据传输。它通过计数器控制对共享资源的访问,防止竞态条件。例如,经典的生产者-消费者问题中,信号量确保缓冲区的安全访问。 数学上,信号量操作基于原子操作: $ P(S): \text{wait} \quad (S = S - 1; \text{ if } S < 0, \text{ block}) $ $ V(S): \text{signal} \quad (S = S + 1; \text{ if } S \leq 0, \text{ wake a process}) $ 这确保了操作的互斥性。 - **套接字(Sockets)** 套接字支持网络IPC,允许不同计算机上的进程通信。它基于TCP/IP协议,提供可靠的双向数据流。例如,Web服务器使用套接字处理客户端请求。 在Windows中,套接字是IPC的重要组成部分,支持跨平台通信[^3]。 - **远程过程调用(RPC, Remote Procedure Call)** RPC使IPC像调用本地函数一样简单,隐藏了底层通信细节。客户端进程调用远程服务器上的函数,RPC框架自动处理数据序列化和网络传输。例如,在分布式系统中,RPC用于构建微服务[^4]。 RPC的优势包括高抽象级别和跨操作系统兼容性,但可能引入延迟问题[^3]。 #### 3. **IPC机制的比较和选择** - **性能考量**:共享内存速度最快(适合大数据量),但需要同步;管道和消息队列简单但较慢;RPC和套接字适用于分布式环境,但网络延迟是瓶颈。 - **实际应用**:在Windows操作系统中,IPC机制(如RPC和套接字)支持高效的数据共享和分布式计算[^1][^3]。在微内核系统(如QNX)中,IPC是核心,用于轻量级模块交互[^2]。 - **安全性和可靠性**:现代IPC机制(如RPC)支持身份验证和加密,防止未授权访问。开发时应根据需求选择机制,例如,实时系统优先选择共享内存。 IPC机制是操作系统的基础,它促进了进程协作,支撑了从单机应用到云计算的广泛场景。理解这些机制有助于优化系统设计和问题诊断[^1][^4]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值