我会做一个假设的场景,只是为了清楚我需要知道的事情。
假设我经常更新一个文件。
我需要通过几个不同的线程来读取和解析这个文件。
每次重写这个文件时,我都会唤醒一个条件互斥,以便其他线程可以做任何他们想做的事。
我的问题是:
如果我有10000个线程,第一个线程执行将阻止执行其他9999个线程?
它是并行还是同步工作?
答案 0 :(得分:3)
这篇文章自Jonathan Wakely首次发布以下地址评论后进行了编辑,并更好地区分了condition_variable,一个条件(在第一个版本中都被称为条件),以及wait函数如何运作。然而,同样重要的是从现代C ++ using std::future
,std::thread
和std::packaged_task
探索更好的方法,并讨论缓冲和合理的线程数。
首先,10,000个线程是很多线程。除了最高性能的计算机之外,线程调度程序将承担很高的负担。 Windows下的典型四核工作站会很困难。这表明某种排队的任务排队是有序的,典型的服务器接受数千个连接,可能使用10个线程,每个线程服务1,000个连接。线程数对问题来说真的不重要,但在如此大量的任务中,10,000个线程是不切实际的。
为了处理同步,互斥锁本身并没有实际执行您提出的建议。您要描述的概念是一种事件对象,可能是一个自动重置事件,它本身就是一个更高级别的概念。 Windows将它们作为其API的一部分,但它们在Linux上(通常用于便携式软件)具有两个基本组件,即互斥锁和条件变量。这些一起创建了自动重置事件,以及其他类型的"等待事件"当Windows调用它们时。在C ++中,这些由std::mutex
和std::condition_variable
提供。
互斥锁本身仅提供对公共资源的锁定控制。在那种情况下,我们不考虑客户端和服务器(或工作人员和执行者),但我们正在考虑同一个资源之间的竞争,只能由一个参与者(线程)访问一次。互斥锁可以阻止执行,但不会根据外部信号释放。如果另一个线程锁定了互斥锁,则互斥锁会阻塞,并无限期地等待,直到锁的所有者释放它。这不是您在问题中出现的情景。
在您的方案中,有许多"客户"和一个服务器"线。服务器负责发出可以处理的信息。所有其他线程都是这种设计中的客户端(线程本身没有任何东西使它们成为客户端,我们只是通过它们执行的函数来认识它们)。在一些讨论中,客户端称为工作线程。
客户端使用互斥/条件变量对来等待信号。此构造通常采用锁定互斥锁的形式,然后使用该互斥锁等待条件变量。当线程在条件变量上输入wait
时,互斥锁将被解锁。对于等待完成工作的所有客户端线程重复此操作。典型的客户端等待示例是:
std::mutex m;
std::condition_variable cv;
void client_thread()
{
// Wait until server signals data is ready
std::unique_lock<std::mutex> lk(m); // lock the mutex
cv.wait(lk); // wait on cv
// do the work
}
这是伪代码,显示一起使用的互斥/条件变量。 std::condition_variable
有两个wait函数重载,这是最简单的一个。目的是线程将阻塞,进入空闲状态,直到发出condition_variable信号。它并不是一个完整的例子,只是指出这两个对象是一起使用的。
Johnathan Wakely在下面的评论是基于wait
不是无限期的事实;无法保证呼叫被解除阻止的原因是由于信号。文档称这是一个虚假的唤醒&#34;,由于操作系统调度的复杂原因,偶尔会发生这种情况。 Johnathan提出的观点是,使用该对的代码必须是安全的,即使唤醒不是因为condition_variable被发出信号。
在使用条件变量的说法中,这被称为条件(而不是condition_variable)。条件是应用程序定义的概念,通常在文献中以布尔值表示,通常是检查bool,整数(有时是原子类型)或调用返回bool的函数的结果。有时应用程序定义的构成真实条件的概念更复杂,但条件的总体影响是确定线程一旦被唤醒,是否应该继续处理,或者只是重复等待。
满足此要求的一种方法是std :: condition_variable :: wait的第二个版本。这两个被宣布:
void wait( std::unique_lock<std::mutex>& lock );
template< class Predicate >
void wait( std::unique_lock<std::mutex>& lock, Predicate pred );
Johnathan的观点是坚持使用第二个版本。但是,文档描述(并且事实上有两个重载表明)Predicate是可选的。 Predicate是某种类型的仿函数,通常是lambda表达式,如果等待应该解除阻塞,则解析为true;如果等待应该继续等待则为false,并且在锁定下进行评估。谓词是条件的同义词,因为谓词是指示等待是否应该解除阻塞的真或假的一种方式。
虽然谓词实际上是可选的,但是等待&#39;等等。在收到信号之前阻塞是不完美的,要求如果使用第一个版本,那是因为应用程序的构造使得虚假的尾流没有后果(实际上,它是设计的一部分)。
乔纳森的引文表明谓词是在锁定下进行评估的,但是在范式的一般形式中,这种形式通常是不切实际的。 std :: condition_variable必须等待锁定的std :: mutex,这可能是保护定义条件的变量,但有时候这是不可能的。有时条件更复杂,外部或微不足道,以至于std :: mutex与条件无关。
要了解在提议的解决方案的上下文中如何工作,假设有10个客户端线程在等待服务器发出要完成工作的信号,并且该工作在队列中被安排为虚拟仿函数的容器。虚拟函子可能类似于:
struct VFunc
{
virtual void operator()(){}
};
template <typename T>
struct VFunctor
{
// Something referring to T, possible std::function
virtual void operator()(){...call the std::function...}
};
typedef std::deque< VFunc > Queue;
上面的伪代码建议一个带有虚拟运算符()的典型函子,返回void并且不带参数,有时称为&#34;盲目调用&#34;。提示它的关键点是Queue可以拥有这些的集合,而不知道被调用的是什么,并且Queue中的任何VFunctors都可以引用std :: function可以调用的任何东西,其中包括其他对象的成员函数,lambdas,简单函数等。但是,如果只有一个函数签名被调用,可能是:
typedef std::deque< std::function<void(void)>> Queue
足够了。
对于这两种情况,只有在队列中有条目时才能完成工作。
要等待,可以使用类似的类:
class AutoResetEvent
{
private:
std::mutex m;
std::condition_variable cv;
bool signalled;
bool signalled_all;
unsigned int wcount;
public:
AutoResetEvent() : wcount( 0 ), signalled(false), signalled_all(false) {}
void SignalAll() { std::unique_lock<std::mutex> l(m);
signalled = true;
signalled_all = true;
cv.notify_all();
}
void SignalOne() { std::unique_lock<std::mutex> l(m);
signalled = true;
cv.notify_one();
}
void Wait() { std::unique_lock<std::mutex> l(m);
++wcount;
while( !signalled )
{
cv.wait(l);
}
--wcount;
if ( signalled_all )
{ if ( wcount == 0 )
{ signalled = false;
signalled_all = false;
}
}
else { signalled = false;
}
}
};
这是标准重置事件类型的可等待对象的伪代码,与Windows CreateEvent
和WaitForSingleObject
API兼容,基本相同。
所有客户端线程都以cv.wait结束(这可能在Windows中使用Windows API超时,但不能使用std::condition_variable
)。在某些时候,服务器通过调用Signalxxx向该事件发出信号。您的方案建议SignalAll()
。
如果调用notify_one,则释放其中一个等待线程,其他所有线程都保持睡眠状态。调用notify_all,然后释放等待该条件的所有线程以便工作。
以下可能是使用AutoResetEvent的示例:
AutoResetEvent evt; // probably not a global
void client()
{
while( !Shutdown ) // assuming some bool to indicate shutdown
{
if ( IsWorkPending() ) DoWork();
evt.Wait();
}
}
void server()
{
// gather data
evt.SignalAll();
}
使用IsWorkPending()
满足条件的概念,正如Jonathan Wakely指出的那样。在指示关闭之前,如果该循环处于未决状态,则该循环将处理工作,否则等待信号。虚假的唤醒没有负面影响。 IsWorkPending()
将检查Queue.size()
,可能通过使用std :: mutex或其他同步机制保护Queue的对象。如果工作处于待处理状态,DoWork()
将从队列中顺序弹出条目,直到队列为空。返回后,循环将再次等待信号。
通过讨论所有这些,mutex和condition_variable的组合与旧的思维方式有关,现在已经过时了,在C ++ 11 / C ++ 14时代已经过时了。除非您在使用兼容编译器时遇到问题,否则最好调查std :: promise,std :: future以及std :: async或std :: thread与std :: packaged_task的使用。例如,使用future,promise,packaged_task和thread可以完全取代上面的讨论。
例如:
// a function for threads to execute
int func()
{
// do some work, return status as result
return result;
}
假设func完成了文件所需的工作,这些typedef适用:
typedef std::packaged_task< int() > func_task;
typedef std::future< int > f_int;
typedef std::shared_ptr< f_int > f_int_ptr;
typedef std::vector< f_int_ptr > f_int_vec;
std :: future无法复制,所以使用shared_ptr存储它以便在矢量中使用,但有各种解决方案。
接下来,将这些用于10个工作线程的示例
void executive_function()
{
// a vector of future pointers
f_int_vec future_list;
// start some threads
for( int n=0; n < 10; ++n )
{
// a packaged_task calling func
func_task ft( &func );
// get a future from the task as a shared_ptr
f_int_ptr future_ptr( new f_int( ft.get_future() ) );
// store the task for later use
future_list.push_back( future_ptr );
// launch a thread to call task
std::thread( std::move( ft )).detach();
}
// at this point, 10 threads are running
for( auto &d : future_list )
{
// for each future pointer, wait (block if required)
// for each thread's func to return
d->wait();
// get the result of the func return value
int res = d->get();
}
}
这里的要点实际上是在最后一个范围 - for循环中。向量存储packaged_tasks提供的期货。这些任务用于启动线程,未来是同步执行者的关键。一旦所有线程都在运行,每个线程都会等待#34;通过简单调用未来的等待函数,之后可以获得func的返回值。不涉及互斥锁或condition_variables(我们知道)。
这让我想到了并行处理文件的主题,无论你如何启动多个线程。如果有一台可以处理10,000个线程的机器,那么如果每个线程都是一个简单的文件导向操作,那么将有相当多的RAM资源用于文件处理,所有这些都相互重复。根据所选的API,每个读操作都有缓冲区。
假设该文件为10 MB,并且10,000个线程开始在其上运行,其中每个线程使用4 Kbyte缓冲区进行处理。结合起来,这表明将有40 MB的缓冲区来处理10 MB的文件。简单地将文件读入RAM并提供对RAM中所有线程的只读访问权将不那么浪费。
由于在不同时间从文件的各个部分读取的多个任务可能会导致标准硬盘(对于闪存源不是这样)的严重颠簸,如果磁盘缓存无法解决这个问题,这个概念会变得更加复杂。赶上。但更重要的是,10,000个线程都是用于读取文件的系统API,每个线程都有相当大的开销。
如果源材料是完全读入RAM的候选者,则线程可以集中在RAM而不是文件上,从而减轻了开销,提高了性能。线程可以在没有锁的情况下共享对内容的读访问权。
如果源文件太大而无法完全读入RAM,则可能最好在源文件的块中读取,让线程从共享内存资源处理该部分,然后移动到系列中的下一个块。