如何使一个类线程安全

26

我正在编写一个C#应用程序。 我有一个(某种类型的)日志记录类,这个日志记录类将被许多线程使用。 如何使这个类是线程安全的? 我应该将其作为单例模式吗? 那么最佳实践是什么? 是否有文档可以阅读以了解如何使其线程安全?

谢谢。


9
单例模式并不意味着线程安全。 - dtb
3
首先仔细定义您所称的“线程安全”。人们使用这个术语,好像它意味着某个具体的东西,但实际上它只是指“在场景X中能够正确工作”。如果没有“正确”规范的说明以及X是什么的声明,您无法实际实现某些东西并确信您解决了真正存在的问题。 - Eric Lippert
1
请查看Joseph Albahari的这篇精彩文章。在文章的中途有一个很棒的“线程安全”部分。 - Kevin Babcock
8个回答

23

在C#中,任何对象都可以用于保护“关键段”,换句话说,即不能同时由两个线程执行的代码。

例如,以下代码将同步访问SharedLogger.Write方法,因此在任何给定时间只有一个线程记录消息。



    public class SharedLogger : ILogger
    {
       public static SharedLogger Instance = new SharedLogger();
       
       public void Write(string s)
       {
          lock (_lock)
          {
             _writer.Write(s);
          }
       }
    
       private SharedLogger() 
       { 
          _writer = new LogWriter();
       }
       
       private object _lock = new object();
       private LogWriter _writer;
    }


以上代码是一个很好的例子。 - Maciek
这很好,但你可以只需将_lock和_writer静态化,而无需处理将此对象设置为单例。不过,使用现有的记录器仍然会更容易。 - Jeff Tucker
5
我同意使用现有的日志实现,但如果他在处理多线程代码时,最终需要知道如何正确地同步访问各种资源,而这个过程也可以应用到其他地方... - jeremyalan
1
关于使用Singleton和静态类,我已经添加了之前遗忘的ILogger接口,以展示在这种情况下,可能希望交换不同的ILogger实现。 - jeremyalan
使用未初始化的_lock(即null)是否可以? - Torbjörn Nomell
@TorbjörnNomell 确实,必须初始化 _lock 对象。 - Lies

12
我不确定我能为使日志记录类线程安全所做的已经被说过的事情再添加什么。正如已经提到的那样,要做到这一点,你必须同步访问资源,即日志文件,以便只有一个线程尝试记录它。C#中的lock关键字是正确的方法。
然而,我将解决(1)单例模式和(2)最终选择使用的方法的可用性。
(1) 如果你的应用程序将所有的日志消息都写入一个日志文件中,那么单例模式绝对是要走的路线。日志文件将在启动时打开并在关闭时关闭,单例模式完美地适合这个操作概念。正如@dtb指出的那样,记住,将一个类变成单例并不能保证线程安全。使用lock关键字来实现。
(2) 至于方法的可用性,请考虑这个建议的解决方案:
public class SharedLogger : ILogger
{
   public static SharedLogger Instance = new SharedLogger();
   public void Write(string s)
   {
      lock (_lock)
      {
         _writer.Write(s);
      }
   }
   private SharedLogger()
   {
       _writer = new LogWriter();
   }
   private object _lock;
   private LogWriter _writer;
}

首先,我要说这种方法通常可行。它通过 Instance 静态变量定义了一个 SharedLogger 的单例实例,并通过私有构造函数防止其他人实例化该类。这就是单例模式的精髓,但我强烈建议在深入学习之前阅读并遵循 Jon Skeet 关于 C#中的单例 的建议。
然而,我想重点关注的是这种解决方案的可用性。所谓 "可用性",是指如何使用此实现记录消息。看一下调用的样子:
SharedLogger.Instance.Write("log message");

整个“Instance”部分看起来不太对,但考虑到实现方式,无法避免。相反,考虑采用以下替代方案:

public static class SharedLogger
{
   private static LogWriter _writer = new LogWriter();
   private static object _lock = new object();
   public static void Write(string s)
   {
       lock (_lock)
       {
           _writer.Write(s);
       }
   }
}

注意现在该类是静态的,这意味着它的所有成员和方法都必须是静态的。它与之前的示例没有实质性区别,但请考虑其用途。
SharedLogger.Write("log message");

使用这种方法编程会更加简单。

重点不是贬低以前的解决方案,而是建议您选择任何解决方案时,可用性是一个重要的方面不容忽视。一个好的、易于使用的API可以使代码编写更简单、更优雅、更易于维护。


尽可能使用静态类是一个经验法则。 - Kelly Elton
我们能不能使用lock(this)代替lock(_this)呢?(使用instant作为锁定对象?) - Silent Byte
创建一个实例然后什么都不做看起来即使对于肉眼来说也是错误的。这样做至少更加清晰。 - I Stand With Russia
静态类无法在C#中实现接口...替代示例代码将无法编译! - drojf
@drojf,你说得对。出于正确性的考虑,我已经从示例中删除了它,因为这样做并不会削弱我所要表达的观点。 - Matt Davis
显示剩余2条评论

7

针对这个问题,我建议使用市面上现成的记录器,因为有很多记录器非常稳定且易于使用,不需要自己编写。 我推荐使用Log4Net。


Log4Net很棒,我用过很多次,总是感到很满意。 - Jeff Tucker
10
我同意这个观点关于记录日志方面的内容。但它并没有回答问题。 - Eric Schoonover

6
  • 尽可能使用本地变量进行大部分计算,然后在一个快速的lock块中更改对象的状态。
  • 请记住,在您读取变量和更改状态之间,某些变量可能会发生变化。

+1 记住第二行,我浪费了两周的时间来理解它 - Silent Byte

3

使用lock()以便多个线程不会同时使用日志记录功能。


2
根据BCS的回答:
BCS描述了一个无状态对象的情况。这样的对象本质上是线程安全的,因为它没有自己的变量,可以被来自不同线程的调用所破坏。
所描述的记录器确实有一个文件句柄(抱歉,我不是C#用户,也许它被称为IDiskFileResource或一些MS-ism),它必须对其进行序列化使用。
因此,将消息的存储与将其写入日志文件的逻辑分开。逻辑应该只操作一条消息。
一种方法是:如果记录器对象保留消息对象队列,并且记录器对象仅具有弹出队列中的消息的逻辑,然后从消息对象中提取有用的内容,然后将其写入日志,然后查找队列中的另一条消息-那么您可以通过使队列的添加/删除/队列大小等操作线程安全来使其线程安全。这将需要记录器类、消息类和线程安全队列(可能是第三个类,其实例是记录器类的成员变量)。

另一种选择(在某些情况下)是让记录器在本地内存中生成完整记录,然后通过单个调用将其写入输出流。如果我没记错的话,大多数操作系统都提供了一个系统调用,可以对(几乎?)任何情况进行原子写入流操作,但这会导致其他问题。 - BCS

1

在我看来,上面提供的代码不再是线程安全的了: 在之前的解决方案中,您必须实例化一个新的SharedLogger对象,并且每个对象都有一个Write方法。

现在你只有一个Write方法,所有线程都会使用它,例如:

线程1: SharedLogger.Write("线程1")

线程2: SharedLogger.Write("线程2");

   public static void Write(string s)
   {
       // thread 1 is interrupted here <=
       lock (_lock)
       {
           _writer.Write(s);
       }
   }
  • 线程1想要写一条消息,但被线程2打断(见注释)
  • 线程2覆盖了线程1的消息,并被线程1打断

  • 线程1获取锁并写入“Thread 2”

  • 线程1释放锁
  • 线程2获取锁并写入“Thread 2”
  • 线程2释放锁

如果我有错误,请纠正我...


1
如果性能不是主要问题,例如类没有承受太大负载,只需执行以下操作:
使您的类继承ContextBoundObject
将此属性应用于您的类[Synchronization]
现在,您的整个类仅可由一个线程访问。
它实际上更有用于诊断,因为速度方面几乎是最坏的情况...但要快速确定“这个奇怪的问题是否是竞争条件”,请使用它,重新运行测试..如果问题消失了..你就知道它是一个线程问题...
一个更高效的选项是使您的日志记录类具有线程安全的消息队列(接受日志消息,然后只需将其顺序地提取并处理...)
例如,新并行处理库中的ConcurrentQueue类是一个很好的线程安全队列。
或者使用已经线程安全的log4net RollingLogFileAppender。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接