C# 多线程同步技术详解
1. 使用 lock 关键字
在多线程代码中,经常需要使用
Monitor
进行同步,而且
try/finally
块很容易被遗忘。因此,C# 提供了
lock
关键字来处理这种锁定同步模式。以下是使用
lock
关键字的示例代码:
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
const int _Total = int.MaxValue;
static long _Count = 0;
public static void Main()
{
Task task = Task.Factory.StartNew(Decrement);
// Increment
for (int i = 0; i < _Total; i++)
{
}
task.Wait();
Console.WriteLine("Count = {0}", _Count);
}
static void Decrement()
{
for (int i = 0; i < _Total; i++)
{
readonly static object _Sync = new object();
lock (_Sync)
{
_Count++;
}
lock (_Sync)
{
_Count--;
}
}
}
}
通过锁定访问
_Count
的代码部分(使用
lock
或
Monitor
),可以使
Main()
和
Decrement()
方法成为线程安全的,即它们可以同时从多个线程中安全调用。
需要注意的是,同步会带来性能开销。例如,上述代码的执行时间比未使用
lock
的代码要长一个数量级。因此,在编写代码时,应避免不必要的同步,以避免死锁和不必要的性能损失。
2. 选择锁定对象
无论是否显式使用
lock
关键字或
Monitor
类,程序员都必须仔细选择锁定对象。在前面的示例中,同步变量
_Sync
被声明为私有和只读的。只读声明确保了在调用
Monitor.Enter()
和
Monitor.Exit()
之间,值不会被更改,从而保证了同步块的进入和退出之间的关联。
同时,将
_Sync
声明为私有可以防止类外部的同步块对同一对象实例进行同步,从而避免代码阻塞。如果数据是公共的,同步对象可以是公共的,但这会增加避免死锁的难度。对于公共数据,最好将同步完全放在类外部,让调用代码使用自己的同步对象进行锁定。
另外,同步对象不能是值类型。如果在值类型上使用
lock
关键字,编译器会报错。因为使用值类型时,运行时会复制值并进行装箱,导致
Monitor.Enter()
和
Monitor.Exit()
接收到不同的同步对象实例,从而无法建立两个调用之间的关联。
3. 避免锁定 this、typeof(type) 和 string
常见的做法是在类的实例数据上使用
this
关键字进行锁定,在静态数据上使用
typeof(type)
进行锁定。然而,这种做法可能会导致不同的同步块相互阻塞,从而产生意外的性能影响,甚至导致死锁。因此,最好定义一个私有、只读的字段作为锁定目标,只有拥有该字段访问权限的类才能进行锁定。
此外,由于字符串驻留机制,应避免使用字符串作为锁定对象。如果相同的字符串常量出现在多个位置,它们可能会引用同一个实例,从而使锁定的范围比预期的要大。
4. 避免使用 MethodImplAttribute 进行同步
在 .NET 1.0 中引入的
MethodImplAttribute
与
MethodImplOptions.Synchronized
方法结合使用时,可以将方法标记为同步,确保同一时间只有一个线程可以执行该方法。然而,这种实现方式会导致同一类中所有使用相同属性和枚举参数修饰的方法都被同步,而且它与
lock(this)
存在相同的问题,因此最好避免使用该属性。
5. 将字段声明为 volatile
编译器和/或 CPU 有时会对代码进行优化,导致指令的执行顺序与代码编写的顺序不一致,或者某些指令被优化掉。在单线程环境中,这种优化通常不会产生问题,但在多线程环境中,可能会导致意外的结果。为了稳定这种情况,可以使用
volatile
关键字声明字段。该关键字强制对
volatile
字段的所有读写操作都在代码指定的位置进行,而不是在优化产生的其他位置进行。
6. 使用 System.Threading.Interlocked 类
前面介绍的互斥模式为处理进程(应用程序域)内的同步提供了基本工具。然而,使用
System.Threading.Monitor
进行同步是一种相对昂贵的操作,而处理器直接支持的
System.Threading.Interlocked
类则提供了一种替代方案,专门针对特定的同步模式。
以下是使用
Interlocked
类的示例代码:
class SynchronizationUsingInterlocked
{
private static object _Data;
// Initialize data if not yet assigned.
static void Initialize(object newValue)
{
// If _Data is null then set it to newValue.
Interlocked.CompareExchange(
ref _Data, newValue, null);
}
// ...
}
Interlocked
类提供了多个同步方法,如下表所示:
| 方法签名 | 描述 |
| — | — |
|
public static T CompareExchange<T>(T location, T value, T comparand);
| 检查
location
是否等于
comparand
。如果相等,则将
location
设置为
value
,并返回
location
中原来存储的数据。 |
|
public static T Exchange<T>(T location, T value);
| 将
location
赋值为
value
,并返回
location
原来的值。 |
|
public static int Decrement(ref int location);
| 将
location
的值减 1,相当于
--
运算符,但
Decrement()
是线程安全的。 |
|
public static int Increment(ref int location);
| 将
location
的值加 1,相当于
++
运算符,但
Increment()
是线程安全的。 |
|
public static int Add(ref int location, int value);
| 将
value
加到
location
上,并将结果赋值给
location
,相当于
+=
运算符。 |
|
public static long Read(ref long location);
| 以单个原子操作返回一个 64 位的值。 |
可以使用
Increment()
和
Decrement()
替代
++
和
--
运算符,以获得更好的性能。但需要注意的是,如果不同的线程使用非
Interlocked
方法访问同一位置,这两个访问将无法正确同步。
7. 多线程事件通知
在触发事件时,开发人员经常会忽略同步问题。以下是一个不安全的线程代码示例:
// Not thread-safe
{
// Call subscribers
OnTemperatureChanged(
this, new TemperatureEventArgs(value) );
}
这段代码在没有竞争条件的情况下是有效的,但由于它不是原子操作,多个线程可能会引入竞争条件。在检查
OnTemperatureChange
是否为
null
到实际触发事件之间,
OnTemperatureChange
可能会被设置为
null
,从而抛出
NullReferenceException
。因此,如果多个线程可能同时访问一个委托,就需要对委托的赋值和触发进行同步。
为了使代码线程安全,可以先复制委托,检查副本是否为
null
,然后触发副本:
// ...
TemperatureChangedHandler localOnChange =
OnTemperatureChanged;
if(localOnChanged != null)
{
// Call subscribers
localOnChanged(
this, new TemperatureEventArgs(value) );
}
// ...
if(OnTemperatureChanged != null)
由于委托是引用类型,将其赋值给局部变量并使用局部变量触发事件可以使
null
检查成为线程安全的操作。因为对
OnTemperatureChange
的任何更改都不会影响
localOnChange
指向的原始委托实例。
8. 同步设计最佳实践
8.1 避免死锁
同步引入了死锁的可能性。死锁发生在两个或多个线程相互等待对方释放同步锁的情况下。例如,线程 1 先请求
_Sync1
的锁,然后在释放
_Sync1
之前请求
_Sync2
的锁;同时,线程 2 先请求
_Sync2
的锁,然后在释放
_Sync2
之前请求
_Sync1
的锁。如果两个线程都成功获取了初始锁,就会导致死锁。
死锁的发生需要满足四个基本条件:
1. 互斥:一个线程(ThreadA)独占某个资源,其他线程(ThreadB)无法获取该资源。
2. 持有并等待:一个拥有互斥资源的线程(ThreadA)正在等待获取另一个线程(ThreadB)持有的资源。
3. 不可抢占:一个线程(ThreadA)持有的资源不能被强行移除,必须由该线程自己释放。
4. 循环等待:两个或多个线程形成一个循环链,它们锁定相同的两个或多个资源,并且每个线程都在等待链中下一个线程持有的资源。
只要移除其中任何一个条件,就可以避免死锁。为了避免死锁,开发人员应确保多个锁的获取顺序始终一致,同时避免使用不可重入的锁。
lock
关键字生成的代码(基于
Monitor
类)是可重入的,但有些锁类型可能不是可重入的。
8.2 何时提供同步
所有静态数据都应该是线程安全的,因此需要对可变的静态数据进行同步。通常,程序员应该声明私有静态变量,并提供公共方法来修改这些数据,这些方法应该在内部处理同步。
相比之下,实例状态通常不需要包含同步。同步可能会显著降低性能,并增加锁竞争或死锁的可能性。除了专门为多线程访问设计的类之外,跨多个线程共享对象的程序员应该自己处理所共享数据的同步。
8.3 避免不必要的锁定
在不影响数据完整性的前提下,程序员应尽可能避免不必要的同步。例如,可以在线程之间使用不可变类型,这样就不需要进行同步(这种方法在函数式编程语言如 F# 中已被证明非常有用)。同样,对于线程安全的操作,如简单的
int
读写操作,应避免锁定。
9. 更多同步类型
9.1 System.Threading.Mutex
System.Threading.Mutex
在概念上与
System.Threading.Monitor
类类似(但不支持
Pulse()
方法),不同的是
lock
关键字不使用它,并且
Mutex
可以命名,从而支持跨多个进程的同步。使用
Mutex
类,可以同步对文件或其他跨进程资源的访问。
以下是一个使用
Mutex
类创建单实例应用程序的示例代码:
using System;
using System.Threading;
using System.Reflection;
class Program
{
public static void Main()
{
// Indicates whether this is the first
// application instance
bool firstApplicationInstance;
// Obtain the mutex name from the full
// assembly name.
string mutexName =
Assembly.GetEntryAssembly().FullName;
using( Mutex mutex = new Mutex(false, mutexName,
out firstApplicationInstance) )
{
if(!firstApplicationInstance)
{
Console.WriteLine(
"This application is already running.");
}
else
{
Console.WriteLine("ENTER to shutdown");
Console.ReadLine();
}
}
}
}
Mutex
类继承自
System.Threading.WaitHandle
,因此包含
WaitAll()
、
WaitAny()
和
SignalAndWait()
方法,允许它自动获取多个锁(这是
Monitor
不支持的)。
9.2 WaitHandle
WaitHandle
是
Mutex
、
EventWaitHandle
和
Semaphore
同步类的基类。
WaitHandle
的关键方法是
WaitOne()
方法,这些方法会阻塞执行,直到
WaitHandle
实例被信号或设置。
WaitOne()
方法有多个重载,支持无限期等待、毫秒级定时等待和
TimeSpan
等待。返回布尔值的版本会在
WaitHandle
在超时之前被信号时返回
true
。
除了实例方法,
WaitHandle
还有两个关键的静态成员:
WaitAll()
和
WaitAny()
。它们也支持超时,并且接受一个
WaitHandle
数组,以便对集合中的任何信号做出响应。
需要注意的是,
WaitHandle
包含一个实现了
IDisposable
的句柄,因此在不再需要时,必须确保正确释放
WaitHandle
。
9.3 重置事件:ManualResetEvent 和 ManualResetEventSlim
重置事件是一种控制线程中特定指令执行顺序的方法。尽管名称中包含“事件”,但重置事件与 C# 委托和事件无关。重置事件可以强制代码等待另一个线程的执行,直到该线程发出信号。这对于测试多线程代码非常有用,因为可以在验证结果之前等待特定状态。
重置事件类型包括
System.Threading.ManualResetEvent
和 .NET Framework 4 引入的轻量级版本
System.Threading.ManualResetEventSlim
。重置事件的关键方法是
Set()
和
Wait()
(在
ManualResetEvent
中称为
WaitOne()
)。调用
Wait()
方法会使线程阻塞,直到另一个线程调用
Set()
方法,或者等待超时。
以下是使用
ManualResetEventSlim
的示例代码:
using System;
using System.Threading;
using System.Threading.Tasks;
public class Program
{
static ManualResetEventSlim MainSignaledResetEvent;
static ManualResetEventSlim DoWorkSignaledResetEvent;
public static void DoWork()
{
Console.WriteLine("DoWork() started....");
Console.WriteLine("DoWork() ending....");
}
public static void Main()
{
using(MainSignaledResetEvent =
new ManualResetEventSlim())
using (DoWorkSignaledResetEvent =
new ManualResetEventSlim())
{
Console.WriteLine(
"Application started....");
Console.WriteLine("Starting task....");
Task task = Task.Factory.StartNew(DoWork);
// Block until DoWork() has started.
DoWorkSignaledResetEvent.Wait();
Console.WriteLine("Thread executing...");
MainSignaledResetEvent.Set();
task.Wait();
Console.WriteLine("Thread completed");
Console.WriteLine(
"Application shutting down....");
}
}
}
ManualResetEventSlim
和
ManualResetEvent
的区别在于,后者默认使用内核同步,而前者经过优化,尽量避免使用内核同步。因此,
ManualResetEventSlim
性能更高,除非需要等待多个事件或跨进程同步,否则一般应使用
ManualResetEventSlim
。
需要注意的是,重置事件实现了
IDisposable
,因此在不再需要时应进行释放。在上述示例中,使用
using
语句确保了这一点。
9.4 高级主题:优先使用 ManualResetEvent 和信号量而不是 AutoResetEvent
除了
ManualResetEvent
和
ManualResetEventSlim
,还有一种重置事件
System.Threading.AutoResetEvent
。与
ManualResetEvent
类似,
AutoResetEvent
允许一个线程通过调用
Set()
方法向另一个线程发出信号,表示该线程已到达代码中的某个位置。不同的是,
AutoResetEvent
只会解除一个线程的
Wait()
调用,因为第一个线程通过自动重置门后,门会再次关闭。
使用
AutoResetEvent
时,很容易错误地编写生产者线程的迭代次数多于消费者线程的代码。因此,通常建议优先使用
Monitor
的
Wait()/Pulse()
模式,或者在特定块中允许少于
n
个线程参与时使用信号量。
与
AutoResetEvent
不同,
ManualResetEvent
直到显式调用
Reset()
方法才会返回未信号状态。
9.5 信号量/SemaphoreSlim 和 CountdownEvent
Semaphore
和
SemaphoreSlim
与
ManualResetEvent
和
ManualResetEventSlim
具有相同的性能差异。与提供“开”或“关”锁定的
ManualResetEvent
/
ManualResetEventSlim
不同,信号量只限制同时进入临界区的调用次数。信号量本质上是对资源池进行计数,当计数达到零时,会阻止对资源池的进一步访问,直到有资源被返回。
CountdownEvent
与信号量类似,但实现的是相反的同步。
CountdownEvent
只有在计数达到零时才允许访问,而不是保护已用完的资源池。例如,在并行下载大量股票报价的操作中,只有当所有报价都下载完成后,特定的搜索算法才能执行。
CountdownEvent
可以用于同步搜索算法,在每个股票下载时递减计数,当计数达到零时释放搜索操作。
需要注意的是,
SemaphoreSlim
和
CountdownEvent
是在 .NET Framework 4 中引入的。
9.6 并发集合类
.NET Framework 4 引入了一系列并发集合类,这些类专门设计为包含内置的同步代码,从而支持多个线程的同时访问,而无需担心竞争条件。并发集合类列表如下:
| 集合类 | 描述 |
| — | — |
|
BlockingCollection<T>
| 提供一个阻塞集合,支持生产者/消费者场景,其中生产者将数据写入集合,消费者从集合中读取数据。该类提供了一个通用的集合类型,可以同步添加和删除操作,而无需关心后端存储(如队列、栈、列表等)。
BlockingCollection<T>
为实现
IProducerConsumerCollection<T>
接口的集合提供阻塞和边界支持。 |
|
*ConcurrentBag<T>
| 一个线程安全的无序集合,包含
T
类型的对象。 |
|
ConcurrentDictionary<TKey, TValue>
| 一个线程安全的字典,包含键值对。 |
|
*ConcurrentQueue<T>
| 一个线程安全的队列,支持先进先出(FIFO)语义,存储
T
类型的对象。 |
|
*ConcurrentStack<T>
| 一个线程安全的栈,支持后进先出(LIFO)语义,存储
T
类型的对象。 |
实现
IProducerConsumerCollection<T>
接口的类(表中标记为
*
)专门设计用于支持生产者和消费者的线程安全访问。这使得一个或多个类可以作为生产者向集合中添加数据,而其他类可以作为消费者从集合中读取数据。
综上所述,在多线程编程中,选择合适的同步技术对于确保代码的正确性和性能至关重要。通过合理使用上述同步工具和遵循最佳实践,可以有效地处理多线程环境中的各种同步问题。
C# 多线程同步技术详解(续)
10. 同步技术总结与应用建议
在多线程编程中,不同的同步技术适用于不同的场景。以下是对前面介绍的同步技术的总结和应用建议:
| 同步技术 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
lock
关键字
| 保护共享资源,确保同一时间只有一个线程可以访问 |
语法简洁,使用方便,基于
Monitor
类实现,可重入
| 性能开销相对较大,可能导致死锁 |
System.Threading.Interlocked
类
| 原子操作,如递增、递减、比较交换等 | 性能高,直接由处理器支持 | 功能相对有限,只能处理特定的同步模式 |
System.Threading.Mutex
| 跨进程同步,如单实例应用程序 | 支持跨进程同步,可命名 | 性能开销较大,使用相对复杂 |
WaitHandle
及其派生类(
ManualResetEvent
、
ManualResetEventSlim
、
Semaphore
、
CountdownEvent
等)
| 线程间的事件通知和同步 | 提供丰富的同步功能,可控制线程的执行顺序 | 使用相对复杂,需要注意资源的释放 |
并发集合类(
BlockingCollection<T>
、
ConcurrentBag<T>
、
ConcurrentDictionary<TKey, TValue>
、
ConcurrentQueue<T>
、
ConcurrentStack<T>
等)
| 多线程同时访问集合 | 内置同步代码,支持多线程并发访问,避免竞争条件 | 可能存在性能开销,具体取决于集合的使用场景 |
在实际应用中,应根据具体需求选择合适的同步技术。例如,如果只是简单的原子操作,优先使用
Interlocked
类;如果需要保护共享资源,可使用
lock
关键字;如果涉及跨进程同步,可考虑使用
Mutex
;如果需要线程间的事件通知和同步,可选择
WaitHandle
及其派生类;如果需要多线程同时访问集合,可使用并发集合类。
11. 同步技术的性能分析
同步操作通常会带来一定的性能开销,因此在使用同步技术时,需要考虑性能因素。以下是几种常见同步技术的性能分析:
-
lock关键字 :lock关键字基于Monitor类实现,使用内核同步机制。虽然语法简洁,但性能开销相对较大,尤其是在高并发场景下,频繁的锁定和解锁操作会导致性能下降。 -
System.Threading.Interlocked类 :Interlocked类提供的方法直接由处理器支持,属于原子操作,性能非常高。因此,在需要进行简单的原子操作时,应优先使用Interlocked类。 -
System.Threading.Mutex:Mutex支持跨进程同步,使用内核对象实现。由于涉及内核调用,性能开销较大,因此应尽量避免在高并发场景下使用。 -
WaitHandle及其派生类 :WaitHandle及其派生类的性能取决于具体的实现方式。例如,ManualResetEventSlim经过优化,尽量避免使用内核同步,性能相对较高;而ManualResetEvent默认使用内核同步,性能开销较大。 - 并发集合类 :并发集合类内置了同步代码,支持多线程并发访问。虽然避免了竞争条件,但由于需要进行同步操作,性能可能会受到一定影响。具体的性能开销取决于集合的使用场景和并发程度。
为了提高多线程程序的性能,应尽量减少同步操作的使用,避免不必要的锁定和解锁操作。同时,可以使用性能分析工具(如 Visual Studio 的性能探查器)来分析程序的性能瓶颈,并进行优化。
12. 同步技术的应用案例
以下是几个使用同步技术的实际应用案例:
12.1 单实例应用程序
在某些情况下,需要确保应用程序只能同时运行一个实例。可以使用
System.Threading.Mutex
来实现这一功能,示例代码如下:
using System;
using System.Threading;
using System.Reflection;
class Program
{
public static void Main()
{
// Indicates whether this is the first
// application instance
bool firstApplicationInstance;
// Obtain the mutex name from the full
// assembly name.
string mutexName =
Assembly.GetEntryAssembly().FullName;
using( Mutex mutex = new Mutex(false, mutexName,
out firstApplicationInstance) )
{
if(!firstApplicationInstance)
{
Console.WriteLine(
"This application is already running.");
}
else
{
Console.WriteLine("ENTER to shutdown");
Console.ReadLine();
}
}
}
}
该代码通过创建一个命名的
Mutex
对象,检查是否已经存在相同名称的
Mutex
实例。如果存在,则表示应用程序已经在运行;否则,表示这是第一个实例。
12.2 生产者 - 消费者模型
生产者 - 消费者模型是多线程编程中常见的模式,其中生产者线程负责生产数据,消费者线程负责消费数据。可以使用
BlockingCollection<T>
来实现这一模式,示例代码如下:
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static BlockingCollection<int> _collection = new BlockingCollection<int>();
static void Producer()
{
for (int i = 0; i < 10; i++)
{
_collection.Add(i);
Console.WriteLine($"Produced: {i}");
Thread.Sleep(100);
}
_collection.CompleteAdding();
}
static void Consumer()
{
foreach (var item in _collection.GetConsumingEnumerable())
{
Console.WriteLine($"Consumed: {item}");
Thread.Sleep(200);
}
}
static void Main()
{
Task producerTask = Task.Run(() => Producer());
Task consumerTask = Task.Run(() => Consumer());
Task.WaitAll(producerTask, consumerTask);
Console.WriteLine("All tasks completed.");
}
}
该代码使用
BlockingCollection<int>
作为生产者和消费者之间的缓冲区。生产者线程将数据添加到集合中,消费者线程从集合中取出数据进行消费。
BlockingCollection<T>
会自动处理同步问题,确保生产者和消费者线程的正确协作。
12.3 多线程数据下载与处理
在某些情况下,需要同时下载多个数据,并在所有数据下载完成后进行处理。可以使用
CountdownEvent
来实现这一功能,示例代码如下:
using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static CountdownEvent _countdownEvent = new CountdownEvent(3);
static void DownloadData(int id)
{
Console.WriteLine($"Downloading data {id}...");
Thread.Sleep(2000);
Console.WriteLine($"Data {id} downloaded.");
_countdownEvent.Signal();
}
static void ProcessData()
{
_countdownEvent.Wait();
Console.WriteLine("All data downloaded. Processing data...");
// 处理数据的代码
}
static void Main()
{
Task[] tasks = new Task[3];
for (int i = 0; i < 3; i++)
{
int id = i + 1;
tasks[i] = Task.Run(() => DownloadData(id));
}
Task processTask = Task.Run(() => ProcessData());
Task.WaitAll(tasks);
processTask.Wait();
Console.WriteLine("All tasks completed.");
}
}
该代码使用
CountdownEvent
来跟踪数据下载的完成情况。每个下载任务完成后,调用
_countdownEvent.Signal()
方法将计数减 1。处理任务调用
_countdownEvent.Wait()
方法等待所有下载任务完成后再进行数据处理。
13. 多线程同步的注意事项
在使用多线程同步技术时,需要注意以下几点:
- 避免死锁 :死锁是多线程编程中常见的问题,会导致程序陷入无限等待状态。为了避免死锁,应确保多个锁的获取顺序始终一致,避免使用不可重入的锁,并尽量减少锁的持有时间。
-
减少同步操作
:同步操作会带来一定的性能开销,因此应尽量减少同步操作的使用。可以使用不可变类型、线程安全的操作(如
Interlocked类的方法)来避免不必要的同步。 -
正确释放资源
:一些同步对象(如
WaitHandle、Mutex等)实现了IDisposable接口,需要在不再使用时进行释放,以避免资源泄漏。可以使用using语句来确保资源的正确释放。 -
考虑性能因素
:不同的同步技术具有不同的性能特点,应根据具体需求选择合适的同步技术。在高并发场景下,应优先选择性能高的同步技术,如
Interlocked类。 - 测试和调试 :多线程程序的调试比较困难,因为线程的执行顺序是不确定的。在编写多线程程序时,应进行充分的测试和调试,确保程序的正确性和稳定性。
14. 总结
多线程编程可以提高程序的性能和响应能力,但也带来了同步问题。在 C# 中,提供了多种同步技术,如
lock
关键字、
System.Threading.Interlocked
类、
System.Threading.Mutex
、
WaitHandle
及其派生类、并发集合类等。不同的同步技术适用于不同的场景,应根据具体需求选择合适的同步技术。
在使用同步技术时,需要注意避免死锁、减少同步操作、正确释放资源、考虑性能因素,并进行充分的测试和调试。通过合理使用同步技术和遵循最佳实践,可以有效地处理多线程环境中的各种同步问题,提高程序的正确性和性能。
希望本文对您理解和掌握 C# 多线程同步技术有所帮助。如果您有任何疑问或建议,欢迎留言讨论。
超级会员免费看
2162

被折叠的 条评论
为什么被折叠?



