This article contains a good description (images taken from there as well).
The approach uses ThreadPool object with EventWaitHandle object to pass messages (objects) between processes (.Net Remoting).
When the application starts, it uses CreateSingleInstance() to call the existing instance OR register itself as single instance application.  

public static bool CreateSingleInstance( string name, EventHandler<InstanceCallbackEventArgs> callback )
{
    EventWaitHandle eventWaitHandle = null;
    int curSessionId = System.Diagnostics.Process.GetCurrentProcess().SessionId;
    name += curSessionId;
    string eventName = string.Format( "{0}-{1}", Environment.MachineName, name );
    // If there is another instance
    InstanceProxy.IsFirstInstance = false;
    InstanceProxy.CommandLineArgs = Environment.GetCommandLineArgs();
    try
    {
        //try to open a handle with the eventName
        eventWaitHandle = EventWaitHandle.OpenExisting( eventName );
    }
    catch
    {
        InstanceProxy.IsFirstInstance = true;
    }
    if( InstanceProxy.IsFirstInstance )
    {
        eventWaitHandle = new EventWaitHandle( false, EventResetMode.AutoReset, eventName );
        // register wait handle for this instance (process)               
        ThreadPool.RegisterWaitForSingleObject( eventWaitHandle, WaitOrTimerCallback, callback, Timeout.Infinite, false );
        eventWaitHandle.Close();
        // register shared type (used to pass data between processes)          
        RegisterRemoteType( name );
    }
    else
    {
      // here will be the code for the second instance/
    }
    return InstanceProxy.IsFirstInstance;
}
private static void RegisterRemoteType( string uri )
{
    // register remote channel (net-pipes)
    var serverChannel = new IpcServerChannel( Environment.MachineName + uri );
    ChannelServices.RegisterChannel( serverChannel, true );
    // register shared type
    RemotingConfiguration.RegisterWellKnownServiceType(
        typeof( InstanceProxy ), uri, WellKnownObjectMode.Singleton );
    // close channel, on process exit
    Process process = Process.GetCurrentProcess();
    process.Exited += delegate
    {
        ChannelServices.UnregisterChannel( serverChannel );
    };
}
[Serializable]
[System.Security.Permissions.PermissionSet( System.Security.Permissions.SecurityAction.Demand, Name = "FullTrust" )]
internal class InstanceProxy : MarshalByRefObject
{
    private static bool firstInstance;
    private static string[] arrCommandLineArgs;       
    public static bool IsFirstInstance
    {
        get
        {
            return firstInstance;
        }
        set
        {
            firstInstance = value;
        }
    }     
    public static string[] CommandLineArgs
    {
        get
        {
            return arrCommandLineArgs;
        }
        set
        {
            arrCommandLineArgs = value;
        }
    }
    public void SetCommandLineArgs( bool isFirstInstance, string[] commandLineArgs )
    {
        firstInstance = isFirstInstance;
        arrCommandLineArgs = commandLineArgs;
    }
}
public class InstanceCallbackEventArgs : EventArgs
{
    private  bool firstInstance;
    private  string[] arrCommandLineArgs;
    internal InstanceCallbackEventArgs( bool isFirstInstance, string[] commandLineArgs )
    {
        firstInstance = isFirstInstance;
        arrCommandLineArgs = commandLineArgs;
    }
    public bool IsFirstInstance
    {
        get
        {
            return firstInstance;
        }
        set
        {
            firstInstance = value;
        }
    }
    public string[] CommandLineArgs
    {
        get
        {
            return arrCommandLineArgs;
        }
        set
        {
            arrCommandLineArgs = value;
        }
    }
}