什么是在实时应用程序中同步多个线程之间的容器访问的最佳方法

时间:2010-01-16 11:13:14

标签: c++ multithreading concurrency access-synchronization

我的应用程序中有std::list<Info> infoList在两个线程之间共享。这两个线程正在访问此列表,如下所示:

主题1 :在列表中使用push_back()pop_front()clear()(视具体情况而定) 主题2 :使用iterator遍历列表中的项目并执行一些操作。

线程2正在迭代列表,如下所示:

for(std::list<Info>::iterator i = infoList.begin(); i != infoList.end(); ++i)
{
  DoAction(i);
}

代码使用GCC 4.4.2编译。

有时++ i会导致段错误并导致应用程序崩溃。该错误是在以下行的std_list.h第143行引起的:

_M_node = _M_node->_M_next;

我猜这是一个竞赛条件。当线程2迭代它时,列表可能已被线程1更改或甚至清除。

我使用Mutex来同步对此列表的访问,并且在我的初始测试期间一切正常。但是系统只是在压力测试下冻结,使得这个解决方案完全不可接受。此应用程序是一个实时应用程序,我需要找到一个解决方案,以便两个线程可以尽可能快地运行,而不会损害总的应用程序吞吐量。

我的问题是: 线程1和线程2需要尽可能快地执行,因为这是一个实时应用程序。我该怎么做才能防止这个问题并仍然保持应用程序性能?是否有任何无锁算法可用于此类问题?

如果我在线程2的迭代中错过了一些新添加的Info对象,那可以,但我能做些什么来阻止迭代器成为悬空指针?

由于

12 个答案:

答案 0 :(得分:5)

你的for()循环可能会在相当长的时间内保持锁定,具体取决于它迭代的元素数量。如果它“轮询”队列,不断检查新元素是否可用,则可能会遇到麻烦。这使得线程在不合理的长时间内拥有互斥锁,几乎没有机会让生产者线程进入并添加一个元素。并且在此过程中燃烧了大量不必要的CPU周期。

你需要一个“有界阻塞队列”。不要自己写,锁的设计并不简单。很难找到好的例子,大部分都是.NET代码。 This article看起来很有希望。

答案 1 :(得分:4)

通常,以这种方式使用STL容器是不安全的。您必须实现特定方法才能使代码线程安全。您选择的解决方案取决于您的需求。我可能会通过维护两个列表来解决这个问题,每个线程一个。并通过lock free queue(在此问题的评论中提到)传达变化。您还可以通过将它们包装在boost :: shared_ptr中来限制Info对象的生命周期,例如

typedef boost::shared_ptr<Info> InfoReference; 
typedef std::list<InfoReference> InfoList;

enum CommandValue
{
    Insert,
    Delete
}

struct Command
{
    CommandValue operation;
    InfoReference reference;
}

typedef LockFreeQueue<Command> CommandQueue;

class Thread1
{
    Thread1(CommandQueue queue) : m_commands(queue) {}
    void run()
    {
        while (!finished)
        {
            //Process Items and use 
            // deleteInfo() or addInfo()
        };

    }

    void deleteInfo(InfoReference reference)
    {
        Command command;
        command.operation = Delete;
        command.reference = reference;
        m_commands.produce(command);
    }

    void addInfo(InfoReference reference)
    {
        Command command;
        command.operation = Insert;
        command.reference = reference;
        m_commands.produce(command);
    }
}

private:
    CommandQueue& m_commands;
    InfoList m_infoList;
}   

class Thread2
{
    Thread2(CommandQueue queue) : m_commands(queue) {}

    void run()
    {
        while(!finished)
        {
            processQueue();
            processList();
        }   
    }

    void processQueue()
    {
        Command command;
        while (m_commands.consume(command))
        {
            switch(command.operation)
            {
                case Insert:
                    m_infoList.push_back(command.reference);
                    break;
                case Delete:
                    m_infoList.remove(command.reference);
                    break;
            }
        }
    }

    void processList()
    {
        // Iterate over m_infoList
    }

private:
    CommandQueue& m_commands;
    InfoList m_infoList;
}   


void main()
{
CommandQueue commands;

Thread1 thread1(commands);
Thread2 thread2(commands);

thread1.start();
thread2.start();

waitforTermination();

}

尚未编译。您仍然需要确保对Info对象的访问是线程安全的。

答案 2 :(得分:3)

我想知道这个清单的目的是什么,那么回答问题会更容易。

正如Hoare所说,尝试共享数据以在两个线程之间进行通信通常是一个坏主意,而不是应该进行通信以共享数据:即消息传递。

例如,如果此列表为队列建模,您可能只是使用两种线程之间的各种通信方式之一(例如套接字)。消费者/生产者是一个标准和众所周知的问题。

如果您的物品价格昂贵,那么只能在通讯过程中传递指针,否则您将避免自行复制物品。

一般来说,分享数据非常困难,但不幸的是,这是我们在学校听到的唯一编程方式。通常,只有低级别的通信“通道”实现应该担心同步,你应该学习使用通道进行通信,而不是试图模仿它们。

答案 3 :(得分:1)

为了防止迭代器失效,您必须锁定整个for循环。现在我猜第一个帖子可能难以更新列表。我会尝试让它有机会在每个(或每第N次迭代)上完成它的工作。

在伪代码中看起来像:

mutex_lock();
for(...){
  doAction();
  mutex_unlock();
  thread_yield();  // give first thread a chance
  mutex_lock();
  if(iterator_invalidated_flag) // set by first thread
    reset_iterator();
}
mutex_unlock();

答案 4 :(得分:1)

您必须决定哪个线程更重要。如果它是更新线程,那么它必须通知迭代器线程停止,等待并重新开始。如果它是迭代器线程,它可以简单地锁定列表,直到迭代完成。

答案 5 :(得分:1)

正如您所提到的那样,如果您的迭代消费者错过了一些新添加的条目,您可以使用下面的 copy-on-write 列表。这允许迭代消费者在列表第一次启动时对列表的一致快照进行操作,而在其他线程中,对列表的更新产生新鲜但一致的副本,而不会扰乱任何现存的快照。

此处的交易是,对列表的每次更新都需要锁定独占访问权限,以便复制整个列表。这种技术偏向于拥有许多并发读者和更少频繁的更新。

首先尝试向容器添加内部锁定需要您考虑在原子组中需要执行哪些操作。例如,在尝试弹出第一个元素之前检查列表是否为空需要原子 pop-if-not-empty 操作;否则,列表空的答案可能会在调用者收到答案并尝试对其进行操作时发生变化。

上面的例子中并不清楚迭代必须遵守的保证。迭代线程必须访问列表中的每个元素吗?它会多次通过吗?当另一个线程针对它运行DoAction()时,一个线程从列表中删除一个元素意味着什么?我怀疑通过这些问题会导致重大的设计变更。


您正在使用C ++,并且您提到需要一个带有 pop-if-not-empty 操作的队列。多年前我使用two-lock queue的并发原语编写了一个ACE Library,因为Boost thread library尚未准备好用于生产,并且包含此类设施的C ++标准库的机会是遥远的梦想。将它移植到更现代的东西很容易。

我的这个队列 - 名为concurrent::two_lock_queue - 只允许通过RAII访问队列的头部。这确保获取锁读取头部将始终与锁的释放配合。使用者构造const_front(对head元素的const访问),front(对head元素的非const访问)或renewable_front(对head和success元素的非const访问) )object表示访问队列head元素的专有权。无法复制此类“前”对象。

two_lock_queue还提供pop_front()函数,该函数等待至少一个元素可用于删除,但与std::queuestd::stack'保持一致不混合容器变异和值复制的风格,pop_front()返回void。

在一个配套文件中,有一个名为concurrent::unconditional_pop的类型,它允许通过RAII确保队列的head元素在退出当前作用域时弹出。

配套文件error.hh定义了使用函数two_lock_queue::interrupt()产生的异常,用于解除阻塞等待访问队列头部的线程。

看看代码,如果您需要更多解释如何使用它,请告诉我。

答案 6 :(得分:1)

执行此操作的最佳方法是使用内部同步的容器。 TBB和Microsoft的concurrent_queue就是这样做的。安东尼·威廉姆斯在他的博客上也有很好的实施here

答案 7 :(得分:1)

其他人已经建议使用无锁替代方案,所以我会回答你好像被卡住了......

修改列表时,现有的迭代器可能会失效,因为它们不再指向有效内存(列表在需要增长时会自动重新分配更多内存)。为了防止无效的迭代器,当你的消费者遍历列表时,可以在互斥锁上生成生产者块,但对于生产者而言不必要等待

如果您使用队列而不是列表,生活会更容易,并让您的消费者使用同步queue<Info>::pop_front()调用而不是可以在您背后无效的迭代器。如果您的消费者确实需要一次吞噬Info块,那么请使用condition variable来阻止消费者阻止queue.size() >= minimum

Boost库有一个很好的条件变量的可移植实现(甚至适用于旧版本的Windows),以及usual threading library stuff

对于使用(老式)锁定的生产者 - 消费者队列,请查看BlockingQueue库的ZThreads模板类。我自己没有使用ZThreads,担心缺乏最近的更新,并且因为它似乎没有被广泛使用。但是,我已经将它用作滚动我自己的线程安全的生产者 - 消费者队列的灵感(在我了解lock-free队列和TBB之前)。

无锁队列/堆栈库似乎位于Boost审核队列中。让我们希望在不久的将来看到一个新的Boost.Lockfree! :)

如果有兴趣,我可以写一个使用std :: queue和Boost线程锁定的阻塞队列的例子。

修改

Rick回答引用的博客已经有一个使用std :: queue和Boost condvars的阻塞队列示例。如果您的消费者需要吞噬块,您可以按如下方式扩展示例:

void wait_for_data(size_t how_many)
    {
        boost::mutex::scoped_lock lock(the_mutex);
        while(the_queue.size() < how_many)
        {
            the_condition_variable.wait(lock);
        }
    }

您可能还想调整它以允许超时和取消。

你提到速度是一个问题。如果您的Info是重量级的,您应该考虑通过shared_ptr传递它们。您也可以尝试使Info固定大小并使用memory pool(可能比堆快得多)。

答案 8 :(得分:1)

如果你正在使用C ++ 0x,你可以通过这种方式在内部同步列表迭代:

  

假设该类有一个名为objects_的模板列表,以及一个名为mutex _的      

toAll方法是列表包装器的成员方法

 void toAll(std::function<void (T*)> lambda)
 {
 boost::mutex::scoped_lock(this->mutex_);
 for(auto it = this->objects_.begin(); it != this->objects_.end(); it++)
 {
      T* object = it->second;
      if(object != nullptr)
      {
                lambda(object);
           }
      }
 }

通话:

synchronizedList1->toAll(
      [&](T* object)->void // Or the class that your list holds
      {
           for(auto it = this->knownEntities->begin(); it != this->knownEntities->end(); it++)
           {
                // Do something
           }
      }
 );

答案 9 :(得分:0)

您必须使用某些线程库。如果您使用的是英特尔TBB,则可以使用concurrent_vector或concurrent_queue。见this

答案 10 :(得分:0)

如果要在多线程环境中继续使用std::list,我建议将其包含在具有互斥锁的类中,该互斥锁提供对它的锁定访问。根据确切的用法,切换到事件驱动的队列模型可能是有意义的,其中消息在多个工作线程正在消耗的队列上传递(提示:生产者 - 消费者)。

我会认真考虑Matthieu's thought。使用多线程编程解决的许多问题可以通过线程或进程之间的消息传递得到更好的解决。如果您需要高吞吐量并且不一定要求处理共享相同的内存空间,请考虑使用类似Message-Passing Interface (MPI)的内容而不是滚动您自己的多线程解决方案。有许多可用的C ++实现 - OpenMPIBoost.MPIMicrosoft MPI等等。

答案 11 :(得分:-1)

在这种情况下,我认为你根本没有任何同步就可以逃脱,因为某些操作使你正在使用的迭代器失效。使用列表,这是相当有限的(基本上,如果两个线程都试图同时操纵迭代器到同一个元素),但仍然存在一个危险,即你在尝试的同时删除一个元素附加一个。

你是否有可能在DoAction(i)之间持锁?您显然只想保持锁定的时间绝对最短,以便最大限度地提高性能。从上面的代码我想你会想要分解循环,以加快操作的两个方面。

有些事情:

while (processItems) {
  Info item;
  lock(mutex);
  if (!infoList.empty()) {
     item = infoList.front();
     infoList.pop_front();
  }
  unlock(mutex);
  DoAction(item);
  delayALittle();
}

插入功能仍然必须如下所示:

lock(mutex);
infoList.push_back(item);
unlock(mutex);

除非队列可能很大,否则我很想使用std::vector<Info>甚至std::vector<boost::shared_ptr<Info> >这样的东西来最小化Info对象的复制(假设这些更多一些)与boost :: shared_ptr相比,复制成本高。通常,向量上的操作往往比列表上的操作快一些,特别是如果存储在向量中的对象很小且复制成本低。