C++11 多线程:数据保护

红薯 发布于 2012/07/04 10:56
阅读 8K+
收藏 63

在编写多线程程序时,多个线程同时访问某个共享资源,会导致同步的问题,这篇文章中我们将介绍 C++11 多线程编程中的数据保护。

数据丢失

让我们从一个简单的例子开始,请看如下代码:

#include <iostream>
#include <string>
#include <thread>
#include <vector>

using std::thread;
using std::vector;
using std::cout;
using std::endl;

class Incrementer
{
    private:
        int counter;

    public:
        Incrementer() : counter{0} { };

        void operator()()
        {
            for(int i = 0; i < 100000; i++)
            {
                this->counter++;
            }
        }

        int getCounter() const
        {
            return this->counter;
        }       
};

int main()
{
    // Create the threads which will each do some counting
    vector<thread> threads;

    Incrementer counter;

    threads.push_back(thread(std::ref(counter)));
    threads.push_back(thread(std::ref(counter)));
    threads.push_back(thread(std::ref(counter)));

    for(auto &t : threads)
    {
        t.join();
    }

    cout << counter.getCounter() << endl;

    return 0;
}

这个程序的目的就是数数,数到30万,某些傻叉程序员想要优化数数的过程,因此创建了三个线程,使用一个共享变量 counter,每个线程负责给这个变量增加10万计数。

这段代码创建了一个名为 Incrementer 的类,该类包含一个私有变量 counter,其构造器非常简单,只是将 counter 设置为 0.

紧接着是一个操作符重载,这意味着这个类的每个实例都是被当作一个简单函数来调用的。一般我们调用类的某个方法时会这样 object.fooMethod(),但现在你实际上是直接调用了对象,如 object(). 因为我们是在操作符重载函数中将整个对象传递给了线程类。最后是一个 getCounter 方法,返回 counter 变量的值。

再下来是程序的入口函数 main(),我们创建了三个线程,不过只创建了一个 Incrementer 类的实例,然后将这个实例传递给三个线程,注意这里使用了 std::ref ,这相当于是传递了实例的引用对象,而不是对象的拷贝。

现在让我们来看看程序执行的结果,如果这位傻叉程序员还够聪明的话,他会使用 GCC 4.7 或者更新版本,或者是 Clang 3.1 来进行编译,编译方法:

g++ -std=c++11 -lpthread -o threading_example main.cpp

运行结果:

[lucas@lucas-desktop src]$ ./threading_example 
218141
[lucas@lucas-desktop src]$ ./threading_example 
208079
[lucas@lucas-desktop src]$ ./threading_example 
100000
[lucas@lucas-desktop src]$ ./threading_example 
202426
[lucas@lucas-desktop src]$ ./threading_example 
172209

但等等,不对啊,程序并没有数数到30万,有一次居然只数到10万,为什么会这样呢?好吧,加1操作对应实际的处理器指令其实包括:

movl    counter(%rip), %eax
addl    $1, %eax
movl    %eax, counter(%rip)

首个指令将装载 counter 的值到 %eax 寄存器,紧接着寄存器的值增1,然后将寄存器的值移给内存中 counter 所在的地址。

我听到你在嘀咕:这不错,可为什么会导致数数错误的问题呢?嗯,还记得我们以前说过线程会共享处理器,因为只有单核。因此在某些点上,一个线程会依照指令执行完成,但在很多情况下,操作系统会对线程说:时间结束了,到后面排队再来,然后另外一个线程开始执行,当下一个线程开始执行时,它会从被暂停的那个位置开始执行。所以你猜会发生什么事,当前线程正准备执行寄存器加1操作时,系统把处理器交给另外一个线程?

我真的不知道会发生什么事,可能我们在准备加1时,另外一个线程进来了,重新将 counter 值加载到寄存器等多种情况的产生。谁也不知道到底发生了什么。

正确的做法

解决方案就是要求同一个时间内只允许一个线程访问共享变量。这个可通过 std::mutex 类来解决。当线程进入时,加锁、执行操作,然后释放锁。其他线程想要访问这个共享资源必须等待锁释放。

互斥(mutex) 是操作系统确保锁和解锁操作是不可分割的。这意味着线程在对互斥量进行锁和解锁的操作是不会被中断的。当线程对互斥量进行锁或者解锁时,该操作会在操作系统切换线程前完成。

而最好的事情是,当你试图对互斥量进行加锁操作时,其他的线程已经锁住了该互斥量,那你就必须等待直到其释放。操作系统会跟踪哪个线程正在等待哪个互斥量,被堵塞的线程会进入 "blocked on m" 状态,意味着操作系统不会给这个堵塞的线程任何处理器时间,直到互斥量解锁,因此也不会浪费 CPU 的循环。如果有多个线程处于等待状态,哪个线程最先获得资源取决于操作系统本身,一般像 Windows 和 Linux 系统使用的是 FIFO 策略,在实时操作系统中则是基于优先级的。

现在让我们对上面的代码进行改进:

#include <iostream>
#include <string>
#include <thread>
#include <vector>
#include <mutex>

using std::thread;
using std::vector;
using std::cout;
using std::endl;
using std::mutex;

class Incrementer
{
    private:
        int counter;
        mutex m;

    public:
        Incrementer() : counter{0} { };

        void operator()()
        {
            for(int i = 0; i < 100000; i++)
            {
                this->m.lock();
                this->counter++;
                this->m.unlock();
            }
        }

        int getCounter() const
        {
            return this->counter;
        }   
};

int main()
{
    // Create the threads which will each do some counting
    vector<thread> threads;

    Incrementer counter;

    threads.push_back(thread(std::ref(counter)));
    threads.push_back(thread(std::ref(counter)));
    threads.push_back(thread(std::ref(counter)));

    for(auto &t : threads)
    {
        t.join();
    }

    cout << counter.getCounter() << endl;

    return 0;
}

注意代码上的变化:我们引入了 mutex 头文件,增加了一个 m 的成员,类型是 mutex,在 operator()() 中我们锁住互斥量 m 然后对 counter 进行加1操作,然后释放互斥量。

再次执行上述程序,结果如下:

[lucas@lucas-desktop src]$ ./threading_example 
300000
[lucas@lucas-desktop src]$ ./threading_example 
300000

这下数对了。不过在计算机科学中,没有免费的午餐,使用互斥量会降低程序的性能,但这总比一个错误的程序要强吧。

防范异常

当对变量进行加1操作时,是可能会发生异常的,当然在我们这个例子中发生异常的机会微乎其微,但是在一些复杂系统中是极有可能的。上面的代码并不是异常安全的,当异常发生时,程序已经结束了,可是互斥量还是处于锁的状态。

为了确保互斥量在异常发生的情况下也能被解锁,我们需要使用如下代码:

   for(int i = 0; i < 100000; i++)
    {
	this->m.lock();
	try
	{
	    this->counter++;
	    this->m.unlock();
	}
	catch(...)
	{
	    this->m.unlock();
	    throw;
	}
    }

但是,这代码太多了,而只是为了对互斥量进行加锁和解锁。没关系,我知道你很懒,因此推荐个更简单的单行代码解决方法,就是使用 std::lock_guard 类。这个类在创建时就锁定了 mutex 对象,然后在结束时释放。

继续修改代码:

void operator()()
{
    for(int i = 0; i < 100000; i++)
    {
	lock_guard<mutex> lock(this->m);

	// The lock has been created now, and immediatly locks the mutex
	this->counter++;

	// This is the end of the for-loop scope, and the lock will be
	// destroyed, and in the destructor of the lock, it will
	// unlock the mutex
    }
}

上面代码已然是异常安全了,因为当异常发生时,将会调用 lock 对象的析构函数,然后自动进行互斥量的解锁。

记住,请使用放下代码模板来编写:

void long_function()
{
    // some long code

    // Just a pair of curly braces
    {
	// Temp scope, create lock
	lock_guard<mutex> lock(this->m);

	// do some stuff

	// Close the scope, so the guard will unlock the mutex
    }
}
英文原文OSCHINA原创翻译
加载中
0
你条草
你条草

Mark!!有空再回头研读!

C++ 11 在什么IDE下支持了!?

之前一直关于多线程的都使用boost::thread,后来发现了个好东西,boost::threadpool实现线程池,而且相当实用!(第三方基于boost::thread,封装的线程池),boost暂时没有收录这个!

你条草
你条草
@clonne 哦 ……基本上常用的就是msvc08……
clonne
clonne
IDE?我以前是VIM+MINGW,现在是Emacs+Mingw,早就不知道什么是IDE了
你条草
你条草
回复 @wixsky : 果然,我是深受微软的毒害啊……要接触一下其他的IDE
wixsky
wixsky
CodeBlocks + MinGW-w64 http://sourceforge.net/projects/mingw-w64/files/
庄严zhuangyan
对了,codeblocks本身也是支持 linux, mac的。gcc就不用说了,最好的版本是linux下。
下一页
0
fair_jm
fair_jm
互斥锁……java中的锁是不会阻止其他线程使用该对象 但会阻止其他线程获得该对象的锁 不知道和这个一不一样
wixsky
wixsky
CodeBlocks + MinGW-w64 http://sourceforge.net/projects/mingw-w64/files/
逝水fox
逝水fox
感觉当作JDK1.5新加的java.util.concurrent.locks.Lock理解更好
0
纠结名字_我艹你妹
纠结名字_我艹你妹

还有一个月 c++ primer 第五版 就出版了

 

纠结名字_我艹你妹
纠结名字_我艹你妹
回复 @千羽鸣 : 你再仔细看看 2012年8月21号
纠结名字_我艹你妹
纠结名字_我艹你妹
回复 @wixsky : 这一本 c++11 讲得特别少!
wixsky
wixsky
C++ Primer Plus(第6版) http://product.dangdang.com/product.aspx?product_id=22783504
吃土的汉子
吃土的汉子
不要一个月,已经出来了 http://www.amazon.com/C-Primer-Plus-5th-Edition/dp/0672326973
0
wixsky
wixsky
还是比较喜欢 pthreads
0
happem
happem
不错,不错。一般像 Windows 和 Linux 系统使用的是 FIFO 策略,在实时操作系统中则是基于优先级的。C++ 多线程的知识文章:http://www.lirenedu.org/
0
egmkang
egmkang
可以无锁编程的,C++11里面有atomic
0
劉前浩
復雜。。。
0
刘地
刘地
结果还不是线程锁
0
l
light_fsq

一般我们调用类的某个方法时会这样object.fooMethod(),但现在你实际上是直接调用了对象,如object().

线程什么时候调用了 operator()?

0
余止败
说得 不错。。
返回顶部
顶部