C++11多态性线程安全且更简洁

3

我正在编写一个日志记录器,并希望使其支持多线程。我通过以下方式实现了这一点:

class Logger
{
public:
    virtual ~Logger();

    LogSeverity GetSeverity() const;
    void SetSeverity(LogSeverity s);
protected:
    std::mutex mutex;
private:
    LogSeverity severity;
};

void Logger::SetSeverity(LogSeverity s)
{
    std::lock_guard<std::mutex> lock(mutex);
    severity = s;
}

LogSeverity Logger::GetSeverity() const
{
    std::lock_guard<std::mutex> lock(mutex);
    return severity;
}

void Logger::SetSeverity(LogSeverity s) const
{
    std::lock_guard<std::mutex> lock(mutex);
    severity = s;
}

// StreamLogger inherits from Logger

void StreamLogger::SetStream(ostream* s)
{
    std::lock_guard<std::mutex> lock(mutex);
    stream = s;
}

ostream* StreamLogger::GetStream() const
{
    std::lock_guard<std::mutex> lock(mutex);
    return stream;
}

然而,所有公共访问该类的操作都需要这个非常冗余的锁。

我看到两个选项:

1)这些公共函数的调用者将使用类内的互斥锁锁定整个对象

Logger l = new Logger();
std::lock_guard<std::mutex> lock(l->lock());
l->SetSeverity(LogDebug);

2) 类中每个变量都有一个包装锁

template typename<T> struct synchronized
{
public:    
    synchronized=(const T &val);
    // etc..
private:
    std::mutex lock;
    T v;
};

class Logger
{
private:
    synchronized<LogSeverity> severity;
};

然而,这种解决方案非常资源密集,需要为每个项目锁定。

我是不是走在正确的道路上?还是我漏掉了什么?


为什么不能在构造函数中设置流和严重程度? - Chris Drew
构造函数将分配合理的默认值(std::cout,LogCritical),公共方法允许用户更改它们。这是一个可用性决策,恐怕将其捆绑到构造函数中以消除问题对我没有好处。 - cvicci
@cvicci,同意Chris的观点。我认为你应该重新考虑你的使用情况。通常情况下,你可以在构造函数或主线程中设置完成的事项,然后再启动其他线程。这样,你就不需要为那些setter/getter加锁了。 - Eric Z
@EricZ 我明白了,然而,我的构造函数将会遇到一个问题,它需要越来越多的参数,最终会变成类似于 StreamLogger(LogSeverity severity, string format, ostream stream, ...更多选项) 的东西。我没有理解我的错误设计决策? - cvicci
顺便提一下,按互斥性分组互斥锁可能会有所帮助。线程同时访问流和严重性是安全的,对吧?这实际上比用户锁定整个对象来执行任何操作更有效率。 - mock_blatt
@cvicci 线程并不是为了实现这种“静态多态性”而发明的。如果您需要自定义类,可以考虑设计模式(例如,工厂模式或策略模式可能适合您的需求)。 - Eric Z
3个回答

1
首先,您需要仔细重新考虑可能的用例:
  • 您是否真的需要使记录器如此可配置?
  • 哪些属性可以在构建期间初始化?
  • 更改所有这些属性是否有意义?
我有一种奇怪的感觉,认为您从非常小的角度考虑了您的类:“好吧,它是一个记录器,所以我会把所有可能有用的功能都放进去”(我可能错了)。类应该具有完整但最小的接口,明确表示特定类负责什么。请考虑一下。
至于您的多线程问题:我不认为共享记录器是一个好主意。在这种情况下,我个人总是更喜欢使用线程特定原语(每个线程一个记录器)。为什么?
  • 如果记录器写入一块内存区域,则只需要锁定内存块,而不是记录器本身
  • 如果记录器写入文件,则您的任务甚至更简单 - 请记住,操作系统管理文件访问,因此您不必担心两个记录器写入同一文件的同一部分(您必须设计您的记录器来确保这一点,但这真的不难)
  • 额外收获:如果需要,不同的线程可以写入不同的输出
如果您的编译器支持C++ 11,上述解决方案基本上是thread_local__declspec(thread)__thread的正确用法,具体取决于您的编译器支持哪种方式。
如果您仍然想要实现共享日志记录器,请从设计审查开始。例如:您确定更改单个属性需要锁定互斥量吗?像severity成员这样的东西是std::atomic的完美候选者。这可能需要更多的工作,但速度会快得多。
class Logger
{
    //cut

private:
    std::atomic<LogSeverity> severity;
};

void Logger::SetSeverity(LogSeverity s)
{
    severity.store(s, std::memory_order_release);
}

LogSeverity Logger::GetSeverity() const
{
    return severity.load(std::memory_order_acquire);
}
std::memory_order_acquire/release 只是一个例子 - 你可能想使用更强的排序方式,比如 memory_order_seq_cst(如果需要全局排序)。然而,acquire/release 对通常足以确保在加载和存储之间进行适当的同步,并且小小的奖励是它们不会在 x86 上产生任何屏障。
如果您想学习线程、原子操作、同步、内存排序等知识,我认为你可能需要阅读 Anthony Williams 的《C++ Concurrency in Action》。这是最好的资源。 Bartosz Milewski's blog上还有非常好的文章,比如:C++ atomics and memory ordering
如果您不熟悉原子操作、屏障、排序等主题,这些资源是入门的很好选择。

0

假设您需要在不同的线程中访问这些setter和getter,这是合理的要求。

我可能错了。但从您所展示的有限代码来看,锁定这些成员的方式是错误的。仅仅锁定成对的setter和getter并不是那么简单的事情。请考虑以下内容:

void tYourClass::thread_1()
{
   ..
   m_streamLogger.SetStream(/*new stream*/);   
}

void tYourClass::thread_2()
{
   ostream *stream = m_streamLogger.GetStream();
   // access the returned stream
   // stream->whatever()
}

在这种情况下,在您获取流句柄并访问它之间,另一个线程会介入并设置该流。会发生什么?您将得到一个“悬空”的流:您可能会访问已删除的对象,或记录一些其他人永远不会看到的东西(取决于SetStream内部的逻辑)。您的锁定未能保护它。根本原因是您应该锁定那些被视为单个“原子”过程执行的语句,在其完成之前,没有其他线程可以介入。

我有两个建议。

  1. 不要在setter/getter中放置任何锁。这要么是因为如上所述它无法保护所有内容,要么是因为效率问题。您可能希望在同一线程中调用这些方法。如果是这种情况,则不需要任何锁。通常,setter和getter本身无法(也不应该)意识到它们将如何被访问。因此,更合理的位置是将锁放在客户端代码中,您认为多线程确实涉及其中。
  2. 不要尝试使用单个锁来保护任何内容。锁定应尽可能短。如果您过度使用单个锁来保护大量独立资源,则并发度(线程的一个主要优点)将受到影响。

这实际上并不是一个锁问题,对吧?他会在一个线程和调用是顺序的情况下遇到这个问题。 - mock_blatt

0
首先,我质疑您是否真的需要那些setter。使类成为不可变的是使其线程安全的最简单方法。
即使您确实使它们线程“安全”,您是否真的希望一个线程在另一个线程正在记录消息时更改目标流?
在这种情况下,看起来您可以在构造函数中简单地设置严重性和流:
StreamLogger(LogSeverity severity, ostream& steam);

如果构造函数参数数量变得难以管理,您可以创建一个构建器或工厂,或者将参数分组到一个对象中:
StreamLogger(const StreamLoggerArgs& arguments);

或者,您可以将记录器中真正需要线程安全的部分分离出来形成一个接口。例如:

class Logger {
  protected:
    ~Logger(){};
  public:
    virtual void log(const char* message) = 0;
    virtual LogSeverity GetSeverity() const = 0;
};

这就是你传递给多个线程的接口,具体实现仍然可以有setter(不一定是线程安全的),如果你愿意的话,但它们只在对象首次设置时从一个线程中使用。


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