令牌环式同步:乒乓球对练的四种实现方式

在并发编程中,"轮流执行"或"交替执行"是一种常见的同步模式。更正式的说法包括令牌环式同步(Token-Ring Synchronization)互斥交替(Mutual Exclusion Alternation)。本文将通过乒乓球对练的经典示例,展示四种不同的实现方式,并分析它们的优缺点。

问题描述

实现两个线程交替打印"Ping"和"Pong",共打印100次,形成"Ping Pong Ping Pong..."的交替序列。

实现方式

1. ManualResetEvent 实现

internal class ManualResetTestCode
{
    public static void Print()
    {
        var ping = new ManualResetEvent(true);
        var pong = new ManualResetEvent(false);

        // 线程 A
        Task.Run(() =>
        {
            for (int i = 0; i < 100; i++)
            {
                ping.WaitOne();    // ManualResetEvent的WaitOne不会自动重置,需要手动调用Reset
                Console.WriteLine("Ping");
                pong.Set();
                ping.Reset();
            }
        });

        // 线程 B
        Task.Run(() =>
        {
            for (int i = 0; i < 100; i++)
            {
                pong.WaitOne();
                Console.WriteLine("Pong");
                ping.Set();
                pong.Reset();
            }
        });
    }
}

工作原理

  • 使用两个ManualResetEvent对象,初始状态分别为true(ping可以立即执行)和false(pong需要等待)
  • 每个线程执行前调用WaitOne()等待信号
  • 执行完成后,通过Set()通知对方线程,同时通过Reset()重置自己的信号

优缺点

  • ✅ 简单直观,易于理解
  • ✅ 可以手动控制信号状态
  • ❌ 需要显式调用Reset(),容易遗漏导致逻辑错误
  • ❌ 性能略差,因为每次都需要手动重置

2. AutoResetEvent 实现

internal class AutoResetControlTestCode
{
    public static void Print()
    {
        var ping = new AutoResetEvent(true);
        var pong = new AutoResetEvent(false);

        // 线程 A
        Task.Run(() =>
        {
            for (int i = 0; i < 100; i++)
            {
                ping.WaitOne();  // WaitOne() 只在事件处于非终止状态时阻塞,并自动重置为false
                Console.WriteLine("Ping");
                pong.Set();
            }
        });

        // 线程 B
        Task.Run(() =>
        {
            for (int i = 0; i < 100; i++)
            {
                pong.WaitOne();
                Console.WriteLine("Pong");
                ping.Set();
            }
        });
    }
}

工作原理

  • 使用两个AutoResetEvent对象,初始状态与ManualResetEvent版本相同
  • WaitOne()方法会自动将事件状态重置为false,无需手动调用Reset()
  • 线程执行完成后,通过Set()通知对方线程

优缺点

  • ✅ 自动重置,减少了手动操作,降低了出错概率
  • ✅ 代码更简洁,逻辑更清晰
  • ✅ 性能比ManualResetEvent稍好
  • ❌ 无法手动控制信号状态,灵活性稍差

3. TaskCompletionSource 实现

public class TaskCompletionSourceTestCode
{
    public static async Task PrintAsync()
    {
        var pingTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
        var pongTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);

        // 先让 ping 侧可以立即发球
        pingTcs.SetResult(true);

        // 线程 A
        var a = Task.Run(async () =>
        {
            for (int i = 0; i < 100; i++)
            {
                await pingTcs.Task;     // 等"可以发 Ping"的信号
                Console.WriteLine("Ping");
                pingTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
                pongTcs.SetResult(true); // 通知对方可以发 Pong
            }
        });

        // 线程 B
        var b = Task.Run(async () =>
        {
            for (int i = 0; i < 100; i++)
            {
                await pongTcs.Task;     // 等"可以发 Pong"的信号
                Console.WriteLine("Pong");
                pongTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
                pingTcs.SetResult(true); // 通知对方可以发 Ping
            }
        });

        await Task.WhenAll(a, b);
    }
}

工作原理

  • 使用TaskCompletionSource创建可等待的任务
  • 初始时,将ping的任务标记为完成,允许ping立即执行
  • 每个线程执行前等待对应的Task完成
  • 执行完成后,创建新的TaskCompletionSource对象,并将对方的任务标记为完成

优缺点

  • ✅ 支持异步编程模型,使用await语法更现代
  • ✅ 避免了线程阻塞,提高了资源利用率
  • ✅ 可以传递实际数据,不仅仅是信号
  • ❌ 每次迭代都需要创建新的TaskCompletionSource对象,有一定的内存开销
  • ❌ 代码相对复杂,需要理解异步编程模型

4. Channel 实现

public class ChannelTestCode
{
    public static async Task PrintChannelAsync()
    {
        var pingCh = Channel.CreateUnbounded<bool>();
        var pongCh = Channel.CreateUnbounded<bool>();

        // 先让 ping 侧可以立即发球
        await pingCh.Writer.WriteAsync(true);

        var a = Task.Run(async () =>
        {
            for (int i = 0; i < 100; i++)
            {
                await pingCh.Reader.ReadAsync(); // 等"可以发 Ping"
                Console.WriteLine("Ping");
                await pongCh.Writer.WriteAsync(true); // 通知对方
            }
        });

        var b = Task.Run(async () =>
        {
            for (int i = 0; i < 100; i++)
            {
                await pongCh.Reader.ReadAsync(); // 等"可以发 Pong"
                Console.WriteLine("Pong");
                await pingCh.Writer.WriteAsync(true); // 通知对方
            }
        });

        await Task.WhenAll(a, b);
    }
}

工作原理

  • 使用Channel创建无界通道
  • 初始时,向ping通道写入数据,允许ping立即执行
  • 每个线程从自己的通道读取数据(等待信号)
  • 执行完成后,向对方通道写入数据(发送信号)

优缺点

  • ✅ 现代异步编程模型,设计更优雅
  • ✅ 高性能,适合高并发场景
  • ✅ 内置了异步支持,无需手动创建任务
  • ✅ 可以轻松扩展为传递复杂数据
  • ❌ 需要.NET Core 3.0+ 或 .NET 5+ 支持
  • ❌ 对于简单场景,可能显得过于复杂

四种实现方式比较

实现方式 技术类型 异步支持 自动重置 性能 代码复杂度 适用场景
ManualResetEvent 传统同步 中等 简单 简单同步场景,需要手动控制信号
AutoResetEvent 传统同步 中等 简单 简单同步场景,自动控制信号
TaskCompletionSource 异步 较高 中等 异步场景,需要灵活控制任务状态
Channel 现代异步 中等 高并发异步场景,需要传递数据

结论

四种实现方式各有优缺点,选择哪种取决于具体场景:

  1. 传统同步场景:如果项目使用的是旧版本.NET Framework,或者需要简单的同步控制,AutoResetEvent是不错的选择。

  2. 异步编程场景:如果项目使用的是.NET Core 3.0+ 或 .NET 5+,Channel是更现代、更高效的选择。

  3. 灵活控制需求:如果需要更灵活地控制任务状态,或者需要传递复杂数据,TaskCompletionSource提供了更多的灵活性。

  4. 简单场景:对于简单的交替执行需求,任何一种实现方式都可以胜任,选择最容易理解和维护的即可。

令牌环式同步是并发编程中的常见模式,通过不同的实现方式,我们可以看到并发编程的演变过程:从传统的同步原语到现代的异步编程模型。了解这些实现方式,有助于我们在实际项目中选择合适的同步机制,编写高效、可靠的并发代码。

扩展思考

  1. 如果需要三个或更多线程交替执行,如何修改这些实现?
  2. 如果线程执行时间不一致,这些实现会有什么问题?如何解决?
  3. 在高并发场景下,哪种实现方式的性能最好?
  4. 如何测试这些实现的正确性和性能?

通过思考这些问题,我们可以更深入地理解并发编程中的同步机制,以及各种实现方式的适用场景。

posted @ 2025-12-02 23:19  孤沉  阅读(5)  评论(0)    收藏  举报