【问题标题】:Is my approach to a threadsafe log class awful?我对线程安全日志类的方法很糟糕吗?
【发布时间】:2013-10-22 13:40:48
【问题描述】:

我一直在寻找解决线程安全日志记录问题的各种方法,但我还没有看到类似的东西,所以我不知道这是否有点糟糕,因为我完全是新手,所以我没有注意到C++、线程和 iostream。它似乎在我通过的基本测试中有效。

基本上我有一个 Log 类(有创意,我知道...),它为标准操纵器设置了 operator

但是,我知道类似:

std::cout << "Threads" << " will" << " mess" << " with" << "this." << std::endl;

当多个线程写入 cout(或 Log ostream 指向的任何位置)时,可能会发生交错。因此,我创建了一些特定于 Log 类的操纵器,让我可以这样做:

Log::log << lock << "Write" << " what" << " I" << " want" << std::endl << unlock;

我只是想知道这是否是一个天生糟糕的想法,记住我愿意接受 Log 类的用户需要接受“锁定”和“解锁”的约束。我考虑过让'std::endl'自动解锁,但这似乎会让人更头疼......我认为无论如何都应该在测试中出现无纪律的使用,但如果有人能看到一种方法来使这种使用导致编译-时间错误,那很好。

我也非常感谢任何关于使我的代码更简洁的建议。

这是一个用于演示目的的简化版;整个事情有更多的构造函数采用文件名之类的东西,所以与问题无关。

#include <iostream>
#include <thread>
#include <fstream>

class Log{
public:
  //Constructors
  Log(std::ostream & os);
  // Destructor
  ~Log();
  // Input Functions
  Log & operator<<(const std::string & msg);
  Log & operator<<(const int & msg);
  Log & operator<<(std::ostream & (*man)(std::ostream &)); // Handles manipulators like endl.
  Log & operator<<(std::ios_base & (*man)(std::ios_base &)); // Handles manipulators like hex.
  Log & operator<<(Log & (*man)(Log &)); // Handles custom Log manipulators like lock and unlock.
  friend Log & lock(Log & log); // Locks the Log for threadsafe output.
  friend Log & unlock(Log & log); // Unlocks the Log once threadsafe output is complete.
private:
  std::fstream logFile;
  std::ostream & logStream;
  std::mutex guard;
};

// Log class manipulators.
Log & lock(Log & log); // Locks the Log for threadsafe output.
Log & unlock(Log & log); // Unlocks the Log once threadsafe output is complete.

void threadUnsafeTask(int * input, Log * log);
void threadSafeTask(int * input, Log * log);

int main(){
  int one(1), two(2);
  Log log(std::cout);
  std::thread first(threadUnsafeTask, &one, &log);
  std::thread second(threadUnsafeTask, &two, &log);
  first.join();
  second.join();
  std::thread third(threadSafeTask, &one, &log);
  std::thread fourth(threadSafeTask, &two, &log);
  third.join();
  fourth.join();
  return 0;
}

void threadUnsafeTask(int * input, Log * log){
  *log << "Executing" << " thread '" << *input << "', " << "expecting " << "interruptions " << "frequently." << std::endl;
}

void threadSafeTask(int * input, Log * log){
  *log << lock << "Executing" << " thread '" << *input << "', " << "not expecting " << "interruptions." << std::endl << unlock;
}

// Constructors (Most left out as irrelevant)
Log::Log(std::ostream & os): logFile(), logStream(logFile), guard(){
  logStream.rdbuf(os.rdbuf());
}

// Destructor
Log::~Log(){
  logFile.close();
}

// Output Operators
Log & Log::operator<<(const std::string & msg){
  logStream << msg;
  return *this;
}

Log & Log::operator<<(const int & msg){
  logStream << msg;
  return *this;
}

Log & Log::operator<<(std::ostream & (*man)(std::ostream &)){
  logStream << man;
  return *this;
}

Log & Log::operator<<(std::ios_base & (*man)(std::ios_base &)){
  logStream << man;
  return *this;
}

Log & Log::operator<<(Log & (*man)(Log &)){
  man(*this);
  return *this;
}

// Manipulator functions.
Log & lock(Log & log){
  log.guard.lock();
  return log;
}

Log & unlock(Log & log){
  log.guard.unlock();
  return log;
}

它适用于我在 Ubuntu 12.04 g++ 上,编译为:

g++ LogThreadTest.cpp -o log -std=c++0x -lpthread

与制作自定义操纵器相关的部分被无耻地抄袭自here,但不要因为我的无能copypasta而责备他们。

【问题讨论】:

  • 这里不使用RAII是错误的。
  • 恕我直言,无锁 FIFO 在这里会是一个更好的主意。不知何故线程暂停日志记录并不吸引人。
  • 依靠用户锁定和解锁某些东西是不可靠的。一种选择是让您的日志文件同时从用户那里获取消息,并通过将它们放在单个队列中并在单独的线程中运行它们来序列化它们。所以从多用户的角度来看,通话是非阻塞的,但实际上没有交错。有关更多信息,请参阅this talk by Herb Sutter。我完成了实现他的并发对象包装器的工作版本的练习。
  • 非常正确:RAII;我应该在销毁时关闭 fstream,是吗?我在削减示例时将其删除,但删除 fstream 会破坏 ostream,因此我将其放回,但没有将 fstream.close() 放入析构函数中...我不知道是否要对 ostream 做任何事情关于销毁 - 如果 Log 实例是用 cerr/cout 构造的,我不想弄乱它,并且如果从 fstream 派生(如果 Log 是用文件名字符串或 fstream 构造的,那些构造函数未在此处显示)它将在关闭时处理,对吗?
  • “依赖用户锁定和解锁某些东西是不可靠的。”正如我所说,我愿意接受这一点 - 但同样,对于在编译时产生此错误的任何建议将不胜感激。

标签: c++ logging thread-safety iostream manipulators


【解决方案1】:

这是个坏主意。 想象一下:

void foo()
{
    throw std::exception();
}

log << lock << "Write" << foo() << " I" << " want" << std::endl << unlock;
                          ^
                          exception!

这会使您的Log 锁定。这很糟糕,因为其他线程可能正在等待锁定。 每次您只是忘记执行unlock 时也会发生这种情况。 您应该在这里使用 RAII:

// just providing a scope
{
    std::lock_guard<Log> lock(log);
    log << "Write" << foo() << " I" << " want" << std::endl;
}

您需要调整您的 lockunlock 方法以具有签名 void lock()void unlock() 并使它们成为 Log 类的成员函数。


另一方面,它相当笨重。请注意,在 C++11 中,使用 std::cout 是线程安全的。这样你就可以轻松做到了

std::stringstream stream;
stream << "Write" << foo() << " I" << " want" << std::endl;
std::cout << stream.str();

完全没有额外的锁。

【讨论】:

  • 关于接受哪个答案的艰难决定,但即使无用的答案提供了一个很好的解决方案,这个答案更直接地回答了这个问题。祝大家干杯。
【解决方案2】:

您不需要显式传递锁操作器,您可以使用哨兵(具有 RAII 语义,正如 Hans Passant 所说)

class Log{
public:
  Log(std::ostream & os);
  ~Log();

  class Sentry {
      Log &log_;
  public:
      Sentry(Log &l) log_(l) { log_.lock(); }
      ~Sentry() { log_.unlock(); }

      // Input Functions just forward to log_.logStream
      Sentry& operator<<(const std::string & msg);
      Sentry& operator<<(const int & msg);
      Sentry& operator<<(std::ostream & (*man)(std::ostream &)); // Handles manipulators like endl.
      Sentry& operator<<(std::ios_base & (*man)(std::ios_base &)); // Handles manipulators like hex.
    };

    template <typename T>
    Sentry operator<<(T t) { return Sentry(*this) << t; }
    void lock();
    void unlock();

private:
  std::fstream logFile;
  std::ostream & logStream;
  std::mutex guard;
};

现在开始写

Log::log << "Write" << " what" << " I" << " want" << foo() << std::endl;

将:

  1. 创建一个临时哨兵对象
    • 锁定 Log 对象
  2. ... 将每个 operator&lt;&lt; 调用转发到父 Log 实例...
  3. 然后在表达式末尾超出范围(或者如果foo 抛出)
    • 解锁 Log 对象

虽然这是安全的,但它也会引起很多争用(在格式化消息时,互斥锁的锁定时间比我通常想要的要长)。一种争用较低的方法是在完全不加锁的情况下将格式化写入本地存储(线程本地或作用域本地),然后持有足够长的锁以将其移动到共享日志队列中。

【讨论】:

  • 啊,我完全误解了 Hans Passant 对 RAII 的引用,但这澄清了它,这是一个很好的解决方案,谢谢!为什么我在寻找一种优雅的方法时找不到这个? :D
  • 恐怕我不记得我第一次看到这个想法的地方了。顺便说一句,您可以通过给 Sentry 一个 std::unique_lock 成员而不是直接调用 lock 和 unlock 来清理它(然后,您可以免费获得 move ctor 和 dtor)。
  • @Useless 通常的解决方案是有一个线程本地的streambuf 和ostream 对象,它 在明确请求时刷新,并锁定刷新。并使用 RAII 方法触发刷新。
  • 虽然我觉得有些不对劲。您的Log::operator&lt;&lt; 按值返回Sentry,这意味着它可能会被复制(在这种情况下,您会过早解锁)。通常,您会侥幸成功,因为 RVO 会消除副本,但我不喜欢代码依赖于 RVO 的事实是正确的。 (我知道我在 20 年前第一次使用这样的东西,当时 RVO 还不是通用的,我需要额外的代码来模拟移动语义。)
  • Dang,我什至无法对此表示赞同,抱歉 - 我接受了 Stefan 的回答,因为它更直接地指出了我的方法存在缺陷的原因,但这一点的启迪受到了极大的赞赏,即使这界面不让我表达。
【解决方案3】:

这不是一个好主意,因为有人会致命 在某些时候忘记unlock,导致所有线程 挂在下一个日志上。还有一个问题是如果 您正在记录的表达式之一抛出。 (不应该 发生,因为您不想在日志中有实际行为 声明,没有任何行为的东西不应该 扔。但你永远不知道。)

记录日志的通常解决方案是使用特殊的临时 对象,它在其构造函数中获取锁,并将其释放 析构函数(并且还执行刷新,并确保存在 尾随'\n')。这可以在 C++11 中非常优雅地完成, 使用移动语义(因为您通常希望创建 函数中临时的实例,但其临时的 析构函数应该在函数之外起作用);在 C++03 中,你 需要允许复制,并确保它只是最终的副本 释放锁。

粗略地说,您的 Log 类看起来像:

struct LogData
{
    std::unique_lock<std::mutex> myLock
    std::ostream myStream;

    LogData( std::unique_lock<std::mutex>&& lock,
             std::streambuf* logStream )
        :  myLock( std::move( lock ) )
        ,  myStream( logStream )
    {
    }

    ~LogData()
    {
        myStream.flush();
    }
};

class Log
{
    LogData* myDest;
public:
    Log( LogData* dest )
        : myDest( dest )
    {
    }
    Log( Log&& other )
        : myDest( other.myDest )
    {
        other.myDest = nullptr;
    }
    ~Log()
    {
        if ( myDest ) {
            delete myDest;
        }
    }
    Log& operator=( Log const& other ) = delete;

    template <typename T>
    Log& operator<<( T const& obj )
    {
        if ( myDest != nullptr ) {
            myDest->myStream << obj;
        }
    }
};

(如果你的编译器没有移动语义,你必须 以某种方式伪造它。如果最坏的情况发生,你可以做 Log mutable 的单指针成员,并将相同的代码放入 具有传统签名的复制构造函数。丑陋,但作为 一种解决方法...)

在这个解决方案中,您将有一个函数log,它返回 此类的一个实例,具有有效的LogData (动态分配)或空指针,取决于是否 日志记录是否处于活动状态。 (可以避免动态 分配,通过使用 LogData 的静态实例 启动日志记录和结束它的函数,但它是 稍微复杂一点。)

【讨论】:

  • 这有点酷,但与stringstream发送消息然后在底层ostream 上调用operator&lt;&lt; 相比,真的有好处吗?另外,unique_lock 的移动 ctor 是否锁定了互斥锁?否则,我认为您实际上永远不会获得锁,对吗?或者你传递一个已经锁定的unique_lock?这里有点困惑。
  • @thokra 与使用ostringstream 相比,有两个重要的好处:第一个是如果日志记录不活动,则不会进行格式化,第二个是我实际上使用了一个特殊的streambuf ,它具有特殊功能,可以在每一行的开头注入代码,并确保刷新的序列以'\n' 结尾。我也使用宏调用log,所以我可以自动传递__FILE____LINE__。而且我一直在使用同一个 streambuf 对象,所以它的缓冲区很快就达到了最大大小,并且没有更多的分配。
  • 锁在函数log中获取,返回Log,构造时必须移入LogData。 (在我的原始代码中,log 函数在构造 LogData 之前有一些事情要做。而且我当时没有线程本地存储,因此可以选择在线程本地存储中使用 streambuf ,并且仅在刷新期间锁定,对我不可用。
猜你喜欢
  • 2015-09-02
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-11-22
相关资源
最近更新 更多