C++0x中对线程的支持

C++0x中对线程的支持(现在标准已经正式命名为C++11)

C++的新标准将会用一个新的库来支持多线程。下面我们来学习如何使用新库来简化代码,减少API和语法的使用。

作者:Anthony Williams http://www.devx.com/SpecialReports/Article/38883

译者:竞天问(这是我第一次翻译文章,也是一次心血来潮,因为我的英语基本功非常得差,之前根本没有想过我还会翻译东西。如果这篇文章有任何地方对您造成了困扰,希望您能与我联系)


C++0x中一个非常重要的特性就是添加了对多线程的支持。在C++0x之前,你的C++编译器所支持的多线程都是作为C++标准的扩展而存在的,也就是说这种支持的很多细节在不同平台和编译器下是不确定的。不论怎样,所有的编译器都会支持新标准的多线程,使其拥有相同的内存模型和 接口 (不过实现者还是可以自由添加扩展的)。这对你来说意味着什么?这意味着你只需要花费很小的代价来使你的代码在不同的平台和编译器下移植。同时你可以不必再学习不同平台和编译器的很多API和语法了。

 

新的线程库的核心是std::thread类,它管理线程的执行,让我们开学学习吧。

 

启动一个线程

你可以用一个函数来构造一个std::thread对象,以启动一个新的线程。这个函数就是线程的入口,一旦函数返回,线程也就结束了:

void do_work();

            std::threadt(do_work);
这就像我们常用的创建线程的API一样,但有一个非常重要的不同:这是C++,所以我们不必受限于只用函数。就像在STL中的众多算法一样,std::thread可以接受重载了括号运算符(operator())的函数对象,就像使用一般的函数一样。
class do_work

    {

    public:

        voidoperator()();

    };

 

    do_workdw;

    std::thread t(dw);
应该注意到非常重要的一点,传给thread的对象是我们提供的对象的复本。如果你就想使用你提供的对象(如果是这样,你最好保证它不会在线程结束前销毁),你可以使用包装类std::ref:
do_work dw;

   std::thread t(std::ref(dw));
许多创建线程的API只允许你给线程传递一个参数,一般是long或void*。std::thread也可以接受参数,不过你可以传递任意多个任意类型(几乎)的参数。是的,你没看错:任意多个。std::thread的构造函数采用了C++0x的新特性:变长参数模板来支持可变数量的参数,就像老式的…变长参数语法,但是新用法是类型安全的。

 

你可以传递任何可复制的对象成为线程函数的参数:

voiddo_more_work(int i,std::string s,std::vector<double> v);

   std::thread

       t(do_more_work,42,"hello",std::vector<double>(23,3.141));
就像线程对象一样,参数对象也是复制进线程里的,所以如果你想传递引用,需要用std::ref对参数进行包装。
voidfoo(std::string&);

   std::string s;

   std::threadt(foo,std::ref(s));

好了,这些足够启动一个线程了。那怎么等待线程结束呢?C++标准称这个行为为“joining”到当前线程(使用了POSIX的术语),你可以使用成员函数join()来达到目的。

voiddo_work();

   std::thread t(do_work);

    t.join();
如果你没想让新线程插入(加入)当前线程,你可以简单的销毁线程对象或调用detach():
voiddo_work();

   std::thread t(do_work);

    t.detach();

现在,我们可以非常好的启动线程了,但是如果你打算在线程间共享数据,你最好保护好它。C++的新标准库当然也提供了标准的方法。

 

保护数据

 

就像大多数线程API一样,在C++0x中,保护数据最基本的是mutex。在C++0x中有4种mutex的变种:

  • 非递归的 (std::mutex)
  • 递归的(std::recursive_mutex)
  • 非递归的且允许在lock()时超时 (std::timed_mutex)
  • 递归的且允许在lock()时超时(std::recursive_timed_mutex)

它们都可以被一个对象所占有。如果你想在一个线程内锁定一个non-recursive mutex两次且中间不解锁的话,行为是未定义的。一个recursive mutex只是简单的增加锁定计数值,锁定几次就可以解锁几次,解锁后其它线程才可以锁定这个mutex。

 

所有这些mutex都有锁定我解锁的成员函数,大多数情景下,std::unique_lock<>和std::lock_guard<>更好。这些类在构造函数中锁定mutex,在析构函数中解锁。这样,如果以局部变量使用,那在它析构时(出了作用域)会自动解锁对应的mutex:

std::mutex m;

    my_classdata;

 

    voidfoo()

    {

       std::lock_guard<std::mutex> lk(m);

       process(data);

    }   // mutex unlocked here
std::lock_guard是故意做的这么简单的,而且你只能像上例那样使用它。另一方面,std::unique_lock允许延迟锁定,尝试锁定,可以超时的尝试锁定,而且会在自身对象销毁前解锁。如果你选择了timed_mutex因为你希望在锁定时可以超时,那你最好使用std::unique_lock:
std::timed_mutex m;

    my_classdata;

 

    voidfoo()

    {

       std::unique_lock<std::timed_mutex>

           lk(m,std::chrono::milliseconds(3)); // wait up to 3ms

       if(lk) // if we got the lock, access the data

           process(data);

}   // mutex unlocked here

这些用于锁定的类是模板,所以它们可以用于所有的标准mutex类型,而且包括所有具有lock()和unlock()函数的附加类型。

 

在锁定多个mutex时防止死锁

 

某些操作偶尔会要求你锁定多个mutex。如果做得不对,就会有非常讨厌的死锁:两个线程以相反次序尝试锁定相同的几个mutex,且都在等待其它线程释放自己正在等待的mutex之后才会释放自己占有的mutex。C++0x标准库alleviates了这个问题,std::lock提供了一次同时锁定多个mutex的能力。代替顺序调用每一个mutex的lock()函数,你可以把它们传递给std::lock(),而且完全没有死锁的风险。你甚至可以传递当前处于解锁状态的std::unique_lock<>对象:

struct X

    {

       std::mutex m;

        inta;

       std::string b;

    };

 

    voidfoo(X& a,X& b)

    {

       std::unique_lock<std::mutex> lock_a(a.m,std::defer_lock);

       std::unique_lock<std::mutex> lock_b(b.m,std::defer_lock);

       std::lock(lock_a,lock_b);

 

        // dosomething with the internals of a and b

}
在上面的例子中,假设你不用std::lock,且一个线程使用foo(x, y),另一个线程使用foo(y, x),这就很有可能引起死锁。但用了std::lock,这样做很安全。

 

在初始化期间保护数据

 

如果你的数据只需要在它初始化时被保护,mutex并不是好的选择。如果你这样做了,会导致在初始化完成后一系列不必要的同步。C++0x针对这种情况提供了几种解决方法。

 

首先,假设你的构造函数是用新的 constexpr 关键字声明的且能满足常量初始化需求。在这种情况下,一个处于静态存储空间的对象,在用构造函数初始化时, C++ 保证这些初始化函数会在任何其它代码运行前执行完毕。现在使用 std::mutex 就是一个可选项了,因为这消除了在全局作用域下初始化混乱的可能性。(这段建议大家参照原文)

class my_class

    {

        inti;

 

    public:

       constexpr my_class():i(0){}

 

       my_class(int i_):i(i_){}

 

        voiddo_stuff();

    };

 

    my_classx; // static initialization with constexpr constructor

 

    intfoo();

    my_classy(42+foo()); // dynamic initialization

 

    void f()

    {

       y.do_stuff(); // is y initialized?

}
第二种选择是使用静态变量。在C++0x中,一个块内的静态变量的初始化是在第一次运行到这行代码时进行的。如果有另一个线程在这个变量的初始化完成之前调用这个函数,则这个线程必需等待:
void bar()

    {

       static my_class z(42+foo()); // initialization is thread-safe

 

       z.do_stuff();

   }

如果这些选择都不适用(有可能对象是动态建立的),那最好使用std::call_once和std::once_flag。就像它们的名字说得一样,当std::call_once和std::once_flag联合使用时,指定的函数只被调用一次:

 

my_class*p=0;

   std::once_flag p_flag;

 

    voidcreate_instance()

    {

        p=newmy_class(42+foo());

    }

 

    voidbaz()

    {

       std::call_once(p_flag,create_instance);

       p->do_stuff();

}

像std::thread的构造函数一样,std::call_once也可以接受函数对象,也可以传递参数给函数。再强调一次,默认情况下,参数是复制进去的,如果你想传递引用,请使用std::ref。

 

等待事件

 

如果你要在线程间共享数据,则会经常遇到一个线程等待另一个线程完成某些操作,而且这种等待不需要耗费任何CPU时间。如果一个线程只是简单的等待自己可以访问某些共享数据,那mutex就足够了。However, generally doing so won't have the desiredsemantics.

最简单的等待方式是让线程周期性睡眠一段时间。如果期望的动作发生了,就唤醒线程向下进行。确保用来保护数据的mutex解锁时线程正在休眠是很重要的。

 

std::mutex m;

    booldata_ready;

 

    voidprocess_data();

 

    voidfoo()

    {

       std::unique_lock<std::mutex> lk(m);

       while(!data_ready)

        {

           lk.unlock();

           std::this_thread::sleep_for(std::chrono::milliseconds(10));

           lk.lock();

        }

       process_data();

    }

这种方法可能是最简单的,但是这不够完美,原因有二:1、在数据准备好后,线程平均需要等待5毫秒(10毫秒的一半)才能进行检查。在某种情况下,这样会造成明显的延迟。尽管可以减少每次等待的时间来改进这个缺点,但这样会恶化第二个缺点:线程每10毫秒就得醒来,申请mutex,检查标志,就算什么都没有发生也得检查。这会花费CPU时间且增加了mutex的争夺时间,这会潜在的拖慢被等待线程的效率!

 

如果你发现你像这样写代码,别!用条件变量代替周期睡眠,你可以使线程一直睡眠,直到被其它线程唤醒。这可以保证线程被唤醒的花费是OS允许的最小值,且有效得使线程在等待的整个过程中CPU时间花费为0。你可以使用条件变量像下面这样重写foo函数:

std::mutexm;

    std::condition_variable cond; 

    booldata_ready;

    voidprocess_data();

 

    voidfoo()

    {

       std::unique_lock<std::mutex> lk(m);

       while(!data_ready)

        {

           cond.wait(lk);

        }

       process_data();

}


我们注意到上面的代码把锁定变量作为参数传递给了wait()。条件变量被实现为在进入wait()时解锁,在函数返回时锁定。这样可以保证被保护的数据可以被其它线程修改,同时当前线程在等待。使data_ready标志置位的代码如下:

voidset_data_ready()

    {

       std::lock_guard<std::mutex> lk(m);

       data_ready=true;

       cond.notify_one();

    }
尽管这样还是需要检查数据是否准备好了,因为条件变量会受到被所谓的“spurious wake”干扰:对wait()的调用可能会在没有得到提醒时被其它线程返回。如果你担心得到这种错误,可以把这个责任交给标准库,通过给定一个断言来告诉标准库你在等待什么。C++0x的lambda特性可以使这个非常简单:

voidfoo()

    {

       std::unique_lock<std::mutex> lk(m);

       cond.wait(lk,[]{return data_ready;});

       process_data();

    }
那如果你不想共享数据呢?如果你想做的与上面正相反呢:给每一个进程一个独自的数据复本?这种情况由新的thread_local关键字解决。

 

线程本地数据


thread_local 关键字可以在局部作用域中用于任何对象的声明,且指明这个变量是线程本地存储的。每一个线程都有一个自己的变量的复本,而且这个变量在整个线程存在期间一直存在。其本质上是每一个线程的静态变量,所以本地化的变量复本的初始化是在第一次执行到变量声明行时进行的,而且它们会保持自己的值直到线程退出:

std::string foo(std::string const& s2)

    {

       thread_local std::string s="hello";

 

       s+=s2;

       return s;

    }
在这个函数里,每一个线程内s的复本以包含字符串”hello”开始生效。每当函数被调用一次,提供的string就会附加到本地的s中。从这个例子里你可以看到,这种方法对于有构造和析构函数的类类型也可以很好的工作,相对于C++0x之前的编译器是一个改进。

语言核心不只提供了线程本地存储:还有很好的支持多线程的内存模型,以支持原子操作。

 

新的内存模型和原子操作

 相比于使用锁和条件变量来保护数据,你不需要操心内存模型。内存模型保证可以在棘手的情况下保护你的数据――前提是你正确使用了锁。如果你没有,得到的将是未定义的行为。

如果你使用非常底层和高效的库,那知道细节就非常重要了――这太复杂了,不应该这样做。现在,知道C++0x拥有一系列对应于内建类型int或void*的原子类型,还可以使用模板std::atomic<>创建原子版的用户自定义类型。你可以去 relevant documentation 查看详细信息。

 谢谢大家!


我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章