令牌环式同步扩展:从 Ping-Pong 到 ABC 交替执行
在上一篇博客中,我们介绍了六种实现两个线程交替执行的方法。本文将作为扩展,探讨如何将这些实现方式改造为三个线程交替执行 "A"、"B"、"C",形成 "ABCABC..." 的环形序列。
问题描述
实现三个线程交替打印 "A"、"B"、"C",共打印 100 轮,形成 "ABCABC..." 的交替序列。
六种实现方式的改造分析
1. ManualResetEvent / AutoResetEvent 实现
改造难度:⭐⭐ 容易
scss
internal class ABC_EventTestCode
{
public static void Print()
{
var a = new AutoResetEvent(true); // A 初始可用
var b = new AutoResetEvent(false); // B 初始不可用
var c = new AutoResetEvent(false); // C 初始不可用
// 线程 A
Task.Run(() =>
{
for (int i = 0; i < 100; i++)
{
a.WaitOne(); // 等待 A 信号
Console.WriteLine("A");
b.Set(); // 释放 B 信号
}
});
// 线程 B
Task.Run(() =>
{
for (int i = 0; i < 100; i++)
{
b.WaitOne(); // 等待 B 信号
Console.WriteLine("B");
c.Set(); // 释放 C 信号
}
});
// 线程 C
Task.Run(() =>
{
for (int i = 0; i < 100; i++)
{
c.WaitOne(); // 等待 C 信号
Console.WriteLine("C");
a.Set(); // 释放 A 信号,形成闭环
}
});
}
}
工作原理:
- 使用三个
AutoResetEvent对象,初始状态分别为true(A 可用)、false(B 不可用)和false(C 不可用) - 每个线程执行前调用
WaitOne()等待信号 - 执行完成后,通过
Set()通知下一个线程,最后一个线程通知第一个线程形成闭环
优缺点:
- ✅ 改造简单,逻辑清晰
- ✅ AutoResetEvent 自动重置,无需手动调用 Reset()
- ✅ 适用于传统同步场景
- ❌ 不支持异步编程,会阻塞线程
- ❌ ManualResetEvent 需要手动 Reset,三个信号时容易漏掉
2. SemaphoreSlim 实现
改造难度:⭐⭐ 容易
csharp
internal class ABC_SemaphoreSlimTestCode
{
// A 初始可用(1),B、C 初始不可用(0)
private SemaphoreSlim semA = new SemaphoreSlim(1, 1);
private SemaphoreSlim semB = new SemaphoreSlim(0, 1);
private SemaphoreSlim semC = new SemaphoreSlim(0, 1);
public void Print()
{
// 线程 A
Task.Run(async () =>
{
for (int i = 0; i < 100; i++)
{
await semA.WaitAsync(); // 等待 A 信号
Console.WriteLine("A");
semB.Release(); // 释放 B 信号
}
});
// 线程 B
Task.Run(async () =>
{
for (int i = 0; i < 100; i++)
{
await semB.WaitAsync(); // 等待 B 信号
Console.WriteLine("B");
semC.Release(); // 释放 C 信号
}
});
// 线程 C
Task.Run(async () =>
{
for (int i = 0; i < 100; i++)
{
await semC.WaitAsync(); // 等待 C 信号
Console.WriteLine("C");
semA.Release(); // 释放 A 信号,形成闭环
}
});
}
}
工作原理:
- 使用三个
SemaphoreSlim对象,初始计数分别为 1(A 可用)、0(B 不可用)和 0(C 不可用) - 每个线程执行前调用
WaitAsync()等待信号 - 执行完成后,通过
Release()增加下一个信号量的计数,最后一个线程释放第一个信号量形成闭环
优缺点:
- ✅ 改造简单,代码简洁
- ✅ 支持异步编程,使用
await语法更现代 - ✅ 轻量级,性能较好
- ✅ 最适合 ABC 场景,代码清晰易维护
- ✅ 支持超时和取消操作
3. TaskCompletionSource 实现
改造难度:⭐⭐⭐ 中等
csharp
internal class ABC_TaskCompletionSourceTestCode
{
public static async Task PrintAsync()
{
var tcsA = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var tcsB = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var tcsC = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
// 初始让 A 可以执行
tcsA.SetResult(true);
// 线程 A
var taskA = Task.Run(async () =>
{
for (int i = 0; i < 100; i++)
{
await tcsA.Task; // 等待 A 信号
Console.WriteLine("A");
tcsA = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
tcsB.SetResult(true); // 通知 B 可以执行
}
});
// 线程 B
var taskB = Task.Run(async () =>
{
for (int i = 0; i < 100; i++)
{
await tcsB.Task; // 等待 B 信号
Console.WriteLine("B");
tcsB = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
tcsC.SetResult(true); // 通知 C 可以执行
}
});
// 线程 C
var taskC = Task.Run(async () =>
{
for (int i = 0; i < 100; i++)
{
await tcsC.Task; // 等待 C 信号
Console.WriteLine("C");
tcsC = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
tcsA.SetResult(true); // 通知 A 可以执行,形成闭环
}
});
await Task.WhenAll(taskA, taskB, taskC);
}
}
工作原理:
- 使用三个
TaskCompletionSource对象 - 初始时,将 A 的任务标记为完成,允许 A 立即执行
- 每个线程执行前等待对应的
Task完成 - 执行完成后,创建新的
TaskCompletionSource对象,并将下一个线程的任务标记为完成,最后一个线程通知第一个线程形成闭环
优缺点:
- ✅ 支持异步编程模型
- ✅ 可以传递实际数据,不仅仅是信号
- ❌ 每次迭代都需要创建三个新的
TaskCompletionSource对象,内存开销较大 - ❌ 代码相对复杂,需要理解异步编程模型
- ❌ 对于纯信号控制场景,有点杀鸡用牛刀
4. Channel 实现
改造难度:⭐⭐ 容易
csharp
internal class ABC_ChannelTestCode
{
public static async Task PrintAsync()
{
var chA = Channel.CreateUnbounded<bool>();
var chB = Channel.CreateUnbounded<bool>();
var chC = Channel.CreateUnbounded<bool>();
// 初始让 A 可以执行
await chA.Writer.WriteAsync(true);
// 线程 A
var taskA = Task.Run(async () =>
{
for (int i = 0; i < 100; i++)
{
await chA.Reader.ReadAsync(); // 等待 A 信号
Console.WriteLine("A");
await chB.Writer.WriteAsync(true); // 通知 B 可以执行
}
});
// 线程 B
var taskB = Task.Run(async () =>
{
for (int i = 0; i < 100; i++)
{
await chB.Reader.ReadAsync(); // 等待 B 信号
Console.WriteLine("B");
await chC.Writer.WriteAsync(true); // 通知 C 可以执行
}
});
// 线程 C
var taskC = Task.Run(async () =>
{
for (int i = 0; i < 100; i++)
{
await chC.Reader.ReadAsync(); // 等待 C 信号
Console.WriteLine("C");
await chA.Writer.WriteAsync(true); // 通知 A 可以执行,形成闭环
}
});
await Task.WhenAll(taskA, taskB, taskC);
}
}
工作原理:
- 使用三个
Channel对象创建无界通道 - 初始时,向 A 通道写入数据,允许 A 立即执行
- 每个线程从自己的通道读取数据(等待信号)
- 执行完成后,向下一个通道写入数据(发送信号),最后一个线程向第一个通道写入数据形成闭环
优缺点:
- ✅ 现代异步编程模型,设计优雅
- ✅ 高性能,适合高并发场景
- ✅ 可以轻松扩展为传递复杂数据
- ❌ 需要 .NET Core 3.0+ 或 .NET 5+ 支持
- ❌ 对于纯信号控制场景,可能显得过于复杂
5. Monitor/lock 实现
改造难度:⭐⭐⭐⭐ 困难
arduino
internal class ABC_MonitorLockTestCode
{
private object lockObj = new object();
private int turn = 0; // 0=A, 1=B, 2=C
public void Print()
{
// 线程 A
Task.Run(() =>
{
for (int i = 0; i < 100; i++)
{
lock (lockObj)
{
while (turn != 0) // 等待自己的回合
{
Monitor.Wait(lockObj); // 释放锁并等待信号
}
Console.WriteLine("A");
turn = 1; // 切换到 B 回合
Monitor.PulseAll(lockObj); // 唤醒所有等待的线程
}
}
});
// 线程 B
Task.Run(() =>
{
for (int i = 0; i < 100; i++)
{
lock (lockObj)
{
while (turn != 1) // 等待自己的回合
{
Monitor.Wait(lockObj); // 释放锁并等待信号
}
Console.WriteLine("B");
turn = 2; // 切换到 C 回合
Monitor.PulseAll(lockObj); // 唤醒所有等待的线程
}
}
});
// 线程 C
Task.Run(() =>
{
for (int i = 0; i < 100; i++)
{
lock (lockObj)
{
while (turn != 2) // 等待自己的回合
{
Monitor.Wait(lockObj); // 释放锁并等待信号
}
Console.WriteLine("C");
turn = 0; // 切换到 A 回合,形成闭环
Monitor.PulseAll(lockObj); // 唤醒所有等待的线程
}
}
});
}
}
工作原理:
- 使用一个共享的
lockObj作为同步对象 - 使用一个整数变量
turn来跟踪当前应该执行的线程(0=A, 1=B, 2=C) - 每个线程在执行前检查是否是自己的回合,如果不是则等待
- 执行完成后,切换回合状态并唤醒所有等待的线程
优缺点:
- ✅ 不需要额外的同步原语,只使用 .NET 内置的 Monitor 机制
- ❌ 改造复杂,代码冗长
- ❌ 不支持异步编程,会阻塞线程
- ❌ 需要使用
PulseAll()唤醒所有线程,性能较差 - ❌ 三个线程竞争锁,容易导致不必要的唤醒
- ❌ 代码复杂度指数增长,难以维护
6. 混合方案:流水线 Channel 实现
改造难度:⭐⭐ 容易
csharp
internal class ABC_PipelineChannelTestCode
{
public static async Task PrintAsync()
{
// 创建两个通道,形成 A → B → C 的流水线
var abChannel = Channel.CreateUnbounded<string>();
var bcChannel = Channel.CreateUnbounded<string>();
// 线程 A - 生产数据
var taskA = Task.Run(async () =>
{
for (int i = 0; i < 100; i++)
{
string data = $"A-{i}";
Console.WriteLine($"A: {data}");
await abChannel.Writer.WriteAsync(data); // 传递给 B
}
abChannel.Writer.Complete(); // 完成写入
});
// 线程 B - 处理数据
var taskB = Task.Run(async () =>
{
await foreach (var data in abChannel.Reader.ReadAllAsync())
{
string processedData = $"B-{data}";
Console.WriteLine($"B: {processedData}");
await bcChannel.Writer.WriteAsync(processedData); // 传递给 C
}
bcChannel.Writer.Complete(); // 完成写入
});
// 线程 C - 消费数据
var taskC = Task.Run(async () =>
{
await foreach (var data in bcChannel.Reader.ReadAllAsync())
{
string finalData = $"C-{data}";
Console.WriteLine($"C: {finalData}");
}
});
await Task.WhenAll(taskA, taskB, taskC);
}
}
工作原理:
- 使用两个
Channel对象创建流水线 - 线程 A 生产数据并写入第一个通道
- 线程 B 从第一个通道读取数据,处理后写入第二个通道
- 线程 C 从第二个通道读取数据并消费
优缺点:
- ✅ 非常适合需要传递数据的场景
- ✅ 现代异步编程模型,设计优雅
- ✅ 高性能,适合高并发场景
- ✅ 代码逻辑清晰,易于理解
- ❌ 对于纯信号控制场景,可能显得过于复杂
- ❌ 需要 .NET Core 3.0+ 或 .NET 5+ 支持
实现方式对比
| 实现方式 | 改造难度 | ABC 适合度 | 推荐场景 |
|---|---|---|---|
| AutoResetEvent | ⭐⭐ | ⭐⭐⭐ | 传统同步,无 async/await |
| SemaphoreSlim | ⭐⭐ | ⭐⭐⭐⭐⭐ | 现代异步,首选 |
| TaskCompletionSource | ⭐⭐⭐ | ⭐⭐ | 需传递复杂数据 |
| Channel | ⭐⭐ | ⭐⭐⭐ | 高并发+数据传递 |
| Monitor/lock | ⭐⭐⭐⭐ | ⭐ | 简单场景,避免多信号 |
| 流水线 Channel | ⭐⭐ | ⭐⭐⭐⭐⭐ | 数据流转(A→B→C) |
通用模式:扩展到 N 个线程
使用 SemaphoreSlim 可以很容易地扩展到 N 个线程的交替执行。以下是一个通用的实现模式:
ini
internal class NThreadsSemaphoreSlimTestCode
{
private List<SemaphoreSlim> semaphores;
private int threadCount;
public NThreadsSemaphoreSlimTestCode(int count)
{
threadCount = count;
semaphores = new List<SemaphoreSlim>();
// 初始化信号量,第一个初始可用,其余初始不可用
for (int i = 0; i < count; i++)
{
semaphores.Add(new SemaphoreSlim(i == 0 ? 1 : 0, 1));
}
}
public void Print()
{
for (int i = 0; i < threadCount; i++)
{
int threadIndex = i;
char threadChar = (char)('A' + threadIndex);
Task.Run(async () =>
{
for (int j = 0; j < 100; j++)
{
// 等待自己的信号
await semaphores[threadIndex].WaitAsync();
Console.WriteLine(threadChar);
// 释放下一个线程的信号,最后一个线程释放第一个线程的信号
int nextIndex = (threadIndex + 1) % threadCount;
semaphores[nextIndex].Release();
}
});
}
}
}
// 使用示例:
// var nThreadsTest = new NThreadsSemaphoreSlimTestCode(5); // 5个线程,交替打印 ABCDE
// nThreadsTest.Print();
结论
从两种线程扩展到三种线程(ABC)的交替执行,不同实现方式的表现差异更加明显:
- SemaphoreSlim 仍然是最佳选择,代码简洁、支持异步、性能良好,尤其是在多线程场景下优势更加突出。
- AutoResetEvent 也是一个不错的选择,改造简单,适合传统同步场景。
- Channel 在需要传递数据的场景下表现优异,尤其是流水线模式非常适合数据处理流程。
- TaskCompletionSource 虽然可以实现,但在多线程场景下代码膨胀严重,内存开销较大。
- Monitor/lock 在多线程场景下表现最差,代码复杂,性能较差,不推荐使用。
选择哪种实现方式,取决于具体的场景需求:
- 纯信号控制 (ABCABC):首选
SemaphoreSlim,代码最简洁,性能最好。 - 需要传递数据 :首选
Channel流水线模式,尤其是在 A→B→C 需要处理数据的场景。 - 传统同步场景 :选择
AutoResetEvent,兼容性好,易于理解。 - 特殊需求:根据具体情况选择其他实现方式。
通过本文的扩展,我们可以看到令牌环式同步模式的灵活性和多样性,以及不同同步原语在不同场景下的适用情况。了解这些实现方式,有助于我们在实际项目中选择合适的同步机制,编写高效、可靠的并发代码。