C++并发编程(二)线程管控

每个C++程序必须至少有一个线程(main()的线程),由C++运行时库(C++ runtime)启动,随后可以发动多线程,它们以别的函数作为入口(entry point),当mian返回时,程序会退出,同理,线程中的入口函数返回时,线程也随之终结。

目录

1.线程的发起

2.线程分离

2.1后台运行线程

3.等待线程完成

3.1抛出异常 

3.2线程安全汇合

4.向线程函数传递参数

4.1参数的隐式转换

4.2传入非const引用 

4.3传递类的成员函数

4.4动态对象归属权的转移

5.线程归属权的转移 

5.1实例间的线程归属权转移

5.2线程在函数间转移

6.使用容器装载线程

7.运行时决定线程数量

8.线程识别

9.总结


1.线程的发起

线程通过构建std::thread对象启动,对象指明线程要运行的任务。任何可调用类型(callable type)都适用于std::thread。因此,可以以重载函数对象操作符的类对象作为参数。

        函数调用操作符是(),因此,此操作符的函数重载是operator()()。重载函数调用操作符的类对象称为函数对象或仿函数(functor),因为我们可以像使用函数名一样使用对象名。

        函数对象比函数指针的效率高,因为可以在编译时确定调用的函数,比运行时效率高,另外可以内联展开减少调用开销。

        函数对象的类继承时,若采用非虚函数,子类或子类的引用只会调用父类的方法;采用虚函数时,子类仍调用基类方法,子类引用才会调用子类本身方法。

转自:C++ 函数调用操作符 () 、 函数对象

#include
#include

class A
{
public:
	void operator()()const
	{
		std::cout << "A" << std::endl;
	}
};

int main()
{
	A a;
	std::thread t(a);
	t.join();
}

上述线程对象t,提供函数对象f作为参数,它被复制到属于新线程的存储空间中,并在那里被调用,由新线程执行。

但是要注意解释性问题,若传入的是匿名变量,如“std::thread t(A())”,本意是发动新线程,但会被解释为函数声明,函数名是t,返回std::thread对象,接收的参数是函数指针,函数没有参数传入,返回A对象,解决办法是多一对括号,或列表初始化或lambda表达式:

std::thread t( (A()) );
std::thread t{ A() };
std::thread t([]{ std::cout<<"A"<

线程启动后,需要明确是要主线程等待其结束,还是让它独立运行。假如等到std::thread销毁之际仍未设置,析构函数会调用std::terminate()终止整个程序:

C++并发编程(二)线程管控_第1张图片

我们要在对象销毁前做出决定(注意,线程本身可能在汇合或分离前已经结束),如果选择detach,分离时线程还未结束运行,那它将继续运行,只有最终从线程函数返回才结束。

2.线程分离

分离操作为会切断线程与std::thread对象的关联,对象不再可汇合(joinable()返回false),对象销毁时也不会调用std::terminate()。

在选择detach的情况下,主线程不会等待子线程,故需要保证子线程访问的外部数据正确、有效。下面这个例子中,函数f以主线程的局部对象state的引用作为参数,子线程调用detach(),表明不等待,在opps()退出后,t可能仍在运行,但t中的do(i)函数需要调用已经销毁的局部对象state,多线程代码容易导致这种错误。

struct func
{
    int& i;
    func(int& i_) : i(i_) {}
    void operator() ()
    {
        for (unsigned j = 0; j < 1000; ++j)
        {
            do_something(i);
        }
    }
};

void oops()
{
    int state = 0;
    func f(state);
    std::thread t(f);
    t.detach();
}

解决办法:令线程函数完全自含(self-contained),即将数据复制到新线程内部,而不共享数据。若可调用对象(可以像函数调用方式的触发调用的对象就是可调用对象)含有指针或引用,则需要谨慎行事。应该尽量避免:在函数中创建线程,线程分离,并让线程访问函数的局部变量引用。

2.1后台运行线程

调用std::thread的detach会使线程在后台运行,无法获得与之关联的std::thread对象,也无法与之直接通信,其归属权和控制权交由C++运行时库(runtime library)处理,线程退出后,与之关联的资源将被正确回收。

UNIX系统中,有一类线程在后台运行且没有对外的用户界面,成为守护线程(daemon process),沿袭这一概念,分离出去的线程也被称为守护线程。它们几乎在程序的整个生命周期都在运行,如:文件系统监控、清除缓存中的无用数据、优化数据结构等。另外,还可以让分离线程执行“启动后即可自主完成的任务”(a fire-and-forget task),还可以通过分离线程确认线程运行完成。

应用举例:文字处理软件,用户可以同时编辑多个文件,当打开新的文件时,创建新的线程运行该文件的编辑窗口,每个文件相互独立,此时可以使用detach线程:

void edit_document(const std::string& file_name )
{
	open_document(file_name);
	while (!finish_editing())
	{
		user_command cmd = get_user_input();
		if (cmd == OPEN_NEW_DOC)
		{
			const std::string name = get_filename_from_user();
			std::thread t(edit_document, name);
			t.detach();
		}
		else
		{
			process_user_input(cmd);
		}
	}
}

3.等待线程完成

若选择等待新线程完成,则调用join()函数即可。对于给定的某个线程,join()仅能调用一次,成员函数bool joinable()用于判断线程是否可汇合。

3.1抛出异常 

如前文所述,std::thread销毁前需要确保已经调用join()或detach()。然而在打算线程汇合时,调用join()的时机需要讲究,因为如果线程启动后有异常抛出,join()尚未执行,则join()将被忽略。我们可以在catch抛出异常前调用join()来避免。

struc func;//与前文一样的函数
void oops()
{
    int state = 0;
    func f(state);
    std::thread t(f);

    try
    {
        ...    
    }
    catch(...)
    {
        t.join();
        throw;
    }

    t.join();
}

3.2线程安全汇合

假如代码必须保证新线程先结束,之后当前线程的函数才返回,关键在于所有的退出路径必须保证这种先后次序,这需要简洁有效的实现方式。我们可以设计一个类,在其析构函数中调用join(),在创建了新线程后构建该类的实例:

class thread_guard
{
    std::thread& t;
public:
    explicit thread_guard(std::thread& t_) : t(t_) {}
    ~thread_guard() 
    {
        if (t.joinable()) t.join();
    }
    thread_guard(thread_guard const&) = delete;
    thread_guard& operator= (thread_guard const&) = delete;
};

struct func
{
    int& i;
    func(int& i_) : i(i_) {}
    void operator() ()
    {
        for (unsigned j = 0; j < 1000; ++j)
        {
            do_something(i);
        }
    }
};

void oops()
{
    int state = 0;
    func f(state);
    std::thread t(f);
    thread_guard g(t);
    ...
}

thread_guard的析构函数会先调用joinable()判断新线程能否汇合。复制构造函数和复制赋值函数以“=delete”标记,均限定不自动生成,因为产生的新对象生存期可能更长,甚至超过与之关联的线程,导致其它问题。

当oops()执行到末尾时,全体局部对象都将被销毁,按照逆向构建的顺序,首先调用thread_guard的实例g的析构函数,使新线程得以调用join()。即便后面的“...”抛出异常导致opps()退出,依然能保证线程汇合。

4.向线程函数传递参数

4.1参数的隐式转换

void f(int i, const std::string& s);
std::thread t(f, 3, "hello");

f()第二个参数为std::string,字符指针仍以const char*形式传入,进入新线程上下文环境后,才转换为std::string。这一细节值得注意,以下例子会有隐患:

void f(int i, const std::string& s);
void oops(int param)
{
	char buffer[1024];
	sprintf(buffer, "%i", param);
	std::thread t(f, 3, buffer);
	t.detach();
}

sprintf 使用使用一些以“%”开头的格式说明符(format specifications)来指定串的格式,在后边的变参列表中提供相应的变量,函数会用相应位置的变量来替代那个说明符,产生一个调用者想要的字符串,该字符串会被打印到第一个参数对应的字符串中。

向线程t中传入的参数有指针buffer,指向char局部数组,我们希望buffer先隐式转换为std::string对象,再将其作为函数参数,然而该操作在线程内才会完成,在此操作完成前oops()函数可能已经退出,局部数组buffer将被销毁,进而引发未定义行为。解决办法是先显式转换,,避免悬空指针

悬空指针(Dangling Pointer):一个指针的指向对象已被删除,那么就成了悬空指针。

野指针:针指向的位置是不可知的、随机的、无明确限制的。(未初始化)

void f(int i, const std::string& s);
void oops(int param)
{
	char buffer[1024];
	sprintf(buffer, "%i", param);
	std::thread t(f, 3, std::string(buffer));
	t.detach();
}

4.2传入非const引用 

如果线程中函数期望接收的参数是非const引用,std::thread会忽视参数要求,直接复制提供的值,当成move-only型别(只能移动,不能复制)以右值形式传递。函数会收到右值引用,与期望的非const引用不符,遂编译失败:

右值rvalue,本质是固定不变的“值”,不接受赋值,只出现在表达式等号右边。特点是没有标识符,无法提取地址,通常是临时变量和匿名变量,满足移动语义。 

void update(int id, m_data& data);
void oops(int id)
{
	m_data data;
	std::thread t(update, id, data);
	t.join();
}

解决办法是以std::ref()函数加以包装:

void update(int id, m_data& data);
void oops(int id)
{
	m_data data;
	std::thread t(update, id, std::ref(data));
	t.join();
}

4.3传递类的成员函数

若要将某个类的成员函数设定为线程函数,则应该传入该函数的指针,以及合适的对象指针:

class A
{
public:
	void work();
};
A my_a;
std::thread t(&A::work, &my_a);

如果要为成员函数提供参数,可以在后边继续添加参数,类似于编译器的绑定机制

在调用类的非静态成员函数时,编译器会隐式添加所操作对象的地址作为第一个参数,用于绑定对象和成员函数。例如:类A具有成员函数func(int x),而a是类A的对象,则调用func(2)等价于调用A::func(&a, 2)。具体参考《深度探索C++对象模型》

4.4动态对象归属权的转移

我们知道,对于给定对象,只能存在唯一一个std::unique_ptr指向它,若该实例被销毁,所指对象便随之删除。通过移动构造函数或移动赋值操作符可以使对象的归属权在多个std::unique_ptr间转移,移动后的源对象的值变成NULL指针。作为临时变量的对象会自动发生移动,具名变量则必须通过调用std::move()请求转移:

void process_obj(std::unique_ptr);
std::unique_ptr p(new obj);
std::thread t(process_obj, std::move(p));

std::move(p)本身只是将p转换成右值,创建线程时发生一次转移,进入线程内部存储空间(线程库内部进行复制),process_obj()开始执行时,再次转移(函数调用复制参数)。

5.线程归属权的转移 

std::thread类的实例可以转移(movable)但不能复制(not copyable),线程的归属权可以在实例间转移,确保了对于每个线程,任何时候只有唯一的std::thread实例与之关联。

5.1实例间的线程归属权转移

以下是线程归属权的例子,注释有每个实例当前绑定的函数对应的线程,以及相关说明:

void func1();
void func2();

std::thread t1(func1);			//t1:func1
std::thread t2 = std::move(t1);	//t1:NULL; t2:func1
t1 = std::thread(func2);		//t1:func2;t2:func1 隐式执行临时变量的移动操作
std::thread t3;					//t1:func2;t2:func;t3:NULL
t3 = std::move(t2);				//t1:func2;t2:NULL;t3:func1
t1 = std::move(t3);				//t1已关联fun2,std::terminate()终止程序

 注意对象管控着一个线程时,不能简单向它赋值,否则线程会被遗弃。

5.2线程在函数间转移

std::thread支持移动操作,所以函数可以便捷地向外部转移线程的归属权(函数返回std::thread对象):

std::thread f()
{
	void func(int);
	return std::thread(func, 1);
}

同样,归属权也可以转入到函数内部(函数参数为std::thread对象),转入时需要注意具名对象和匿名对象的区别:

void f(std::thread t);
void h()
{
	void func();
	//匿名对象自动调用std::move()
	f(std::thread(func));
	//具名对象
	std::thread t(func);
	f(std::move(t));
}

在线程支持移动语义的情况下,我们可以把线程的归属权转移给thread_guard,其它对象就无法执行该线程的汇合和分离操作了。

前文我们使用thread_guard类在一定程度上保证了线程退出前调用join(),但是一旦thread_guard的生命周期超过管控的线程,还是容易引发各种错误。基于移动语义,我们可以设计scop_thread类,用于确保对象在离开所在作用域前,其线程已经完成:

class scope_thread
{
	std::thread t;
public:
	explicit scope_thread(std::thread t_) : t(std::move(t_))
	{
		if (!t.joinable())
			throw std::logic_error("No thread!");
	}
	~scope_thread()
	{
		t.join();
	}
	scope_thread(const scope_thread&) = delete;
	scope_thread& operator=(const scope_thread&) = delete;
};
struct func
{
    int& i;
    func(int& i_) : i(i_) {}
    void operator() ()
    {
        for (unsigned j = 0; j < 1000; ++j)
        {
            do_something(i);
        }
    }
};
void f()
{
	int state;
	scope_thread t{ std::thread(func(state)) };//解决匿名变量的解释问题
}

这里向scope_thread的构造函数传入新线程,此处state传入的是func而不是std::thread,所以不需要考虑非const参数,与前文的guard_theard不同,此类在构造函数中判断线程能否汇合。初始线程运行至f()末尾,对象t将被销毁,调用析构函数中的join()。在C++20中引入了std::jthread,只要执行析构函数,线程便能自动汇合。

6.使用容器装载线程

基于std::thread的移动语义特性,只要容器同样支持移动意图(move-aware),就可以装载std::thread对象,因此我们可以用vector容器装载多个std::thread对象,这种方法可以用于切分某算法的运算任务:

移动意图(move-aware)指容器能够将元素复制或移动到其内部,可以正确处理只移动对象,且能在容器内部进行移动操作。

void func(int i);
void f()
{
	std::vector threads;
	for (int i = 0; i < 20; ++i)
	{
		threads.emplace_back(func, i);
	}
	for (auto& entry : threads)
	{
		entry.join();
	}
}

上述代码中,使用emplace_back方法直接在vector末尾创建std::thread对象,每个线程上的任务都是自含的,且操作共享数据的结果只由副作用产生。假如f()需要返回值,且返回值依赖于所有线程的结果,那么只有等所有线程执行完毕后,才能核查共享数据,以推算出返回值。

自含(self-contained)指线程内数据齐全,可独立完成子任务而不依赖外部数据。

副作用,C++标准中规定的特定术语,意思是执行状态的改变,一般是计算表达式时对存储在变量中的值进行的修改,此处线程中函数的结果仅依赖于i的值的改变。

7.运行时决定线程数量

我们可以借用C++标准库的std::thread::hardware_concurrency()函数,它返回一个指标,表示程序在各次运行中可真正并发的线程数量,若信息无法获取,则返回0,它能帮助我们完成多线程对完整任务的分解。

以下代码将工作分派给各个线程,并设置一个限定量,每个线程所负责的元素数量都不能低于限定量,从而防止线程过多(线程过饱和oversubscription)造成额外开销。假设没有任何操作抛出异常,后续章节将介绍可直接调用的库函数,此处给出大致思路:

template
struct accumulate_block
{
	void operator()(Iterator first, Iterator last, T& result)
	{
		result = std::accumulate(first, last, result);//(累加起始位置,累加终止位置,累加初始值)
	}
};
template
T parallel_accumulate(Iterator first, Iterator last, T init)
{
	unsigned long const length = std::distance(first, last);//元素总长度[first, last)
	if (!length)//表明没有元素,返回起始元素
	{
		return Init;
	}
	unsigned long const min_per_thread = 25;//每个线程最少处理的元素数
	unsigned long const max_threads = (length + min_per_thread - 1) / min_per_thread;//最大线程数的计算方式,保证了至少有一个线程。
	unsigned long const hardware_threads = std::thread::hardware_concurrency();//获取实际线程数
	unsigned long const num_threads = std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads);//实际的线程数在2-max_threads之间。
	unsigned long const block_size = length / num_threads;//按照各线程需要分担的元素数量,将数据切分为多块
	std::vector results(num_threads);//用于存放中间结果

	std::vector threads(num_threads - 1);//用于装载线程,-1因为主线程也算一个线程
	Iterator block_start = first;//初始化block_start迭代器
	for (unsigned long i = 0; i < (num_threads - 1); ++i)//循环创建子线程
	{
		Iterator block_end = block_start;//初始化block_end迭代器
		std::advance(block_end, block_size);//block_end迭代器移动至小块末尾
		threads[i] = std::thread(//启动新线程
			accumulate_block(),//调用函数计算小块累加结果
			block_start,
			block_end,
			std::ref(results[i])
		);
		block_start = block_end;//因为STL容器左闭右开特性,下一小块的起始位置为本小块的末端
	}

	accumulate_block()(//主线程处理最后一个小块,直接处理到last解决余数问题
		block_start, last, results[num_threads - 1]);
	//等待所有线程完成
	for (auto& entry : threads) 
	{
		entry.join();
	}

	return std::accumulate(results.begin(), results.end(), init);//最后返回累加值
}

本例没有考虑部分类型T(如double和float)的精度损失问题;本例中的迭代器必须是前向迭代器;且为了创建vector results存储中间结果,T必须支持默认构造(default-constructible)。由于无法从线程直接返回值,必须传入results[i]引用,后续章节会介绍future方式从线程返回结果。

上例中我们把变量i赋值给线程ID,若某函数需要获取线程ID就不太方便,C++标准库为每个线程内建了唯一的ID。

8.线程识别

线程ID的型别是std::thread::id,可以在与线程关联的std::thread对象上调用成员函数get_id()获得线程ID,若std::thread对象没有关联任何线程,则std::thread::get_id()将会返回默认构造的std::thread::id对象,表示“线程不存在”。

该型别可随意进行复制且具备完整的比较运算符,比较运算符就所有不相等的值确立了全序关系,用于判断线程是否相同,或排序,或参与任何用途的比较运算,更重要的是能作为关联容器(associative container,包括std::set、std::map、std::multiset、std::multimap)的键值

全序关系(total order)关于集合的数学概念,满足3个性质:反对称、传递和完全。

我们可以用线程ID识别主线程,使主线程和子线程用不同的方式处理任务,只要在发起新线程前保存std::this_thread::get_id()的结果:

std::thread::id main_thread = std::this_thread::get_id();
void func()
{
	if (std::this_thread::get_id() == main_thread)
	{
		do_main_work();
	}
	do_common_work();
}

我们可以采用关联容器,以线程ID为键值存储每个线程的相关信息,从而关联特定的数据或控制特定线程处理数据的方式。还可以打印出ID用于调试和日志:

std::cout<

9.总结

基础

std::thread对象创建新线程,创建时可用{}解决解释性问题,新线程需要调用join()或detach(),joinable()成员函数判断是否可汇合join()\分离detach(),不能重复选择join和detach。

join()要注意在抛出异常前或线程结束前调用。解决办法:1.抛出异常时catch中调用join();2.创建新类,并在析构函数中调用join(),令复制构造和复制赋值函数=delete,新类在创建新线程后构建。

detach()要注意避免:含有局部指针或引用对象时,局部对象被销毁,而线程仍在运行。

在向线程函数传入参数时

若线程分离,且参数为局部变量的指针/引用,要注意线程内隐式转换导致出现悬空指针,此时改用显示转换。

若函数接收非const引用,要使用std::ref(),按引用传递参数(默认是复制为右值)。

线程处理成员函数时,要额外提供成员对象指针/引用作为第一参数。

转移归属权

函数可以返回std::thread对象向外转移归属权,或接收std::thread对象将归属权转移到内部。(具名对象使用std::move())。

std::thread对象作为构造函数参数可以设计安全类。(保证线程及时汇合/分离)

其它

std::thread::hardware_concurrency()返回线程真正并发数量,以此为依据设计数据块。

std::this_thread::get_id()识别当前线程ID。

你可能感兴趣的:(C++并发,c++)