C++并发编程实战-提炼总结-第二章:线程管控

2023-12-14 00:07:28

引言

  • 经过第一章,我们决定利用多线程技术应用程序实现并发
  • C++标准库std::thread对象线程进行关联,以此对线程进行管控
  • C++标准库还提供了基础构建单元实现对复杂任务的管控
  • 本章将讨论的内容如下:
    1. 如何发起一个线程?如何等待线程结束使线程在后台运行
    2. 如何在启动线程向线程的起始函数传递参数
    3. 如何把线程的归属权从某个std::thread对象转移给另一个?
    4. 合适的线程数量是多少?怎样识别特定的线程

2.1 线程的基本管控

  • 每个C++程序都至少有一个线程,即运行main()的线程,它由C++运行时(C++ Runtime)系统启动
  • 可以在main线程的程序(代码)中发起新的线程,发起了新线程后又可以在新线程的程序中发起更多的线程
  • 总结:每个新线程都由一个父线程发起,而线程树的根节点是main线程

(1)简单的程序

  • 线程通过构造std::thread对象启动,该对象通过构造函数中可调用参数指明了线程要执行的程序(代码)
  • 最简单的程序就是一个普通函数,返回空且参数列表为空,将一个普通函数作为std::thread对象的构造参数启动新线程的示例代码如下:
#include <iostream>
#include <thread> // 包含多线程相关声明的头文件

// 普通函数
void hello()
{
	std::cout << "Hello Concurrent World!" << std::endl;
}

// main线程
int main()
{
	std::thread t(hello); // 将一个普通函数作为std::thread对象的构造参数启动新线程
	
	t.join();			  // 在main线程中等待t对象关联的线程结束
	
	return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 可以看到新线程运行了std::thread对象构造函数中的可调用实参hello新线程会运行hello函数直到其返回hello函数返回后线程也终止
  • std::thread对象构造参数中的可调用实参也叫做起始函数,因为线程会从这个函数开始执行,当然此函数内部可以调用其他函数。
  • 上例中的程序(函数)是一个非常简单的函数,与之相反的是复杂的程序,它接受参数,并且在运行过程中受到其他消息系统协调按照固定逻辑执行一系列独立操作,直到接收到某些信号时才会停止

(2)更多的可调用类型

  • 自定义类和lambda表达式等都可以作为可调用类型,使用它们也可以构造std::thread对象。
  • std::thread被包含在**< thread >头文件中,任何可调用类型都可以构造std::thread对象**。我们可以重载自定义类的"()"运算符,使得该类的实例对象变成一个可调用对象定义一个可调用类并使用其构造std::thread对象以发起线程的示例代码如下:
#include <iostream>
#include <thread>

class Test
{
public:
	void operator()()	// 重载()运算符
	{
		std::cout << "Hello World!" << std::endl;
	}
};

// main线程
int main()
{
	Test test;	// 构造一个可调用对象

	std::thread t(test); // 将一个可调用对象作为std::thread对象的构造参数启动新线程
	
	t.join();	// 在main线程中等待t对象关联的线程结束
	
	return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 要小心C++代码的二义性避免编译器定义对象的语句解释为函数声明。我们还可以使用lambda表达式构造std::thread对象,使用lambda的好处是能捕获某些局部变量,而无需另外传递参数,示例代码如下:
#include <iostream>
#include <thread>

// main线程
int main()
{
	std::string str = "Hello World!";	// 在main线程中定义了字符串str

	std::thread t([=]() {				// 使用lambda表达式构造thread类,并使用main线程中的字符串str
		std::cout << str << std::endl;
		});
	
	t.join();							// 使main线程等待新线程结束

	return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 如何发起带有输入参数等复杂类型程序的线程会在后文提及。
  • 总之不管线程要执行什么程序、从程序哪里发起只要是通过C++标准库启动线程就一定会构造std::thread对象。

(3)线程的管理

  • 通过上文我们知道了,使用C++标准库发起线程的方法就是构造std::thread对象
  • 构造完std::thread对象后,其与线程相关联,线程也处在运行中。这时我们就要考虑:创建此线程的父线程是要等待子线程结束,还是任由其独自运行。父线程等待子线程结束,很像是两条线的汇合,而父线程任由子线程运行,则像是两条线的分离
  • 如果线程未结束,但与其关联的std::thread将要被销毁,即调用了std::thread对象的析构函数,那么其析构函数将调用std::terminate()此函数会报错使你的程序终止。因此我们必须在关联了线程thread对象析构前,决定父线程是和子线程是汇合还是分离
  • 总结:一个关联了线程thread对象,如果在线程还未结束时析构,会导致报错。因此我们必须在thread对象析构前,决定将与它关联的线程和父线程是**“汇合”**还是“分离”尽管此时线程可能早已结束,但我们必须规定好所有
  • 示例程序如下,父线程选择等待子线程,达到“汇合”的效果:
#include <iostream>
#include <thread>

// 子线程程序:向父线程问好
void hello()
{
	std::cout << "Hello Father!" << std::endl;
}

// main线程
int main()
{
	std::thread t(hello); // 发起子线程

	std::cout << "Hello Son!" << std::endl;	// 向子线程问好
	
	t.join();	// 等待子线程结束,此代码执行完毕后父子线程“汇合”

	return 0;
}
  • 运行效果:
    在这里插入图片描述
  • 可以看到上述示例中父线程在执行完自己的工作(向子线程问好)后,选择等待子线程,t.join()函数执行完毕代表父线程和子线程的任务都执行完毕了(因为t.join调用在父线程最后)。从性能角度考量,父线程和子线程都应尽量工作(执行程序),而join函数使父线程等待子线程会浪费性能,因此一般将等待放在最后即父线程分出多个子线程后去做自己的事,当父线程做完自己的事时等待每一个子线程返回,最终达到所有线程任务执行完毕的效果
  • 除了等待子线程,父线程还可以任由子线程运行,达到分离线程的效果。父线程调用std::thread对象的成员函数detach()即可实现与子线程的分离,调用后thread对象与对应线程会解除关联,线程在后台运行,thread对象可以随时析构不会调用std::terminate()函数
  • 分离线程后无法获取与它关联的std::thread对象,也无法与其汇合。分离线程运行在后台,其归属权和控制权都转移给C++运行库当其退出时,与其关联的资源会被正确回收
  • 示例程序如下,父线程分离子线程,析构没有关联线程的thread对象,父子线程彼此并行运行。
#include <iostream>
#include <thread>
#include <windows.h>	// 使用其包含的Sleep函数

// 子线程程序:不停打印更大的数字
void hello() 
{
	int i = 0;
	while (1)
	{
		std::cout << i++ << std::endl;
		Sleep(10);		// 打印慢一点,不然截屏只能看到一者线程的输出
	}
}

// main线程
int main()
{
	std::thread t(hello); // 发起子线程

	t.detach();			  // 分离子线程(子线程与t解除关联)

	t.~thread();		  // 析构未关联线程的thread对象t

	while (1)			  // 父线程:打印1
	{
		std::cout << 1 << std::endl;
		Sleep(10);
	}

	return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 构造一个std::thread对象时,其与一个线程相关联,因此调用其成员函数joinable()会返回true。但是如果调用了一个std::thread对象的join或者detach方法,其成员函数会返回false,表示其不关联任何线程,此后不能再调用其join和detach方法

(4)线程的注意事项

  • 如果父线程不等待子线程结束,那么在子线程结束之前,我们必须保证它所访问的外部数据始终正确和有效。在单线程代码中,试图访问已经销毁的对象是未定义且经常出现的行为
  • 新线程的起始函数中包含指针或引用,指向其他线程的局部变量时,若新线程还未终止其他线程就结束,那么会导致上述未定义的行为
  • 示例代码,在线程中访问无效外部数据:
#include <iostream>
#include <thread>

class func
{
public:
	int& i;	
	func(int& _i) :i(_i) {}
	void operator()()	// 子线程
	{
		for (int j = 0; j < 10000000000; j++)
		{
			std::cout << i << std::endl;
		}
	}
};

void oops()
{
	int k = 0;
	func my_func(k);
	std::thread t(my_func);	// 发起新线程
	t.detach();				// 分离新线程
}

// main线程
int main()
{
	oops();
	while (1);
	return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 可以看到func类示例my_func中的成员i是引用类型,并且引用的是oops函数中的局部变量,当新线程分离后反复执行时oops函数可能已经返回了,因此访问i的行为是未定义的。尽管输出结果看起来没问题,但这是错误并且有很大风险的多线程代码中很可能出现这种错误并且就算有错误也不一定发生不一定能检查到
  • 解决上述问题的处理方法是:令线程函数完全自含数据,即将数据复制到新线程内部,而不是共享数据。如果可调用对象含有指针或者引用,即如果新线程必须要访问外部数据,那么必须保证外部数据的正确和有效性让新线程与父线程汇合可以保证父线程在新线程之后退出
  • 还有一个注意的点是:当父线程发生异常时,跳过了子线程的汇合和分离处理,于是就析构与子线程相关联的std::thread对象怎么办?
  • 一个直观的方法是:发起新线程后,父线程在try catch块中执行代码,并在catch块中和块外调用join或detach方法。示例如下:
#include <iostream>
#include <thread>

void f()
{
	std::thread t;	// 发起子线程
	try
	{
		// 父线程程序
	}
	catch (const std::exception&)
	{
		t.join();	// 如果检测到异常也执行汇合或分离,保证t析构不会报错
	}
	t.join();		// 如果没有检测到异常,正常执行汇合或分离
}

// main线程
int main()
{
	f();
	return 0;
}
  • 上述代码保证了在父线程退出函数f之前,和子线程汇合或分离。但上述代码并不是解决异常的理想方案,因为稍显冗余并且容易引发作用域错乱。另一种更好的方法是构造一个类,示例如下:
#include <iostream>
#include <thread>

// 使用类存储thread对象
class thread_guard
{
public:
	std::thread& t;	// 记录thread对象

	explicit thread_guard(std::thread& t_) :t(t_) {}	// 构造函数

	~thread_guard()	// 析构函数,会使其记录thread对象可能关联的线程汇合或分离
	{
		if (t.joinable())
		{
			t.join();
		}
	}
	thread_guard(thread_guard const&) = delete;				// 禁用拷贝构造
	thread_guard& operator=(thread_guard const&) = delete;	// 禁用赋值
};

void f()
{
	std::thread t;
	thread_guard tg(t);
	// 父线程程序
}

// main线程
int main()
{
	f();
	return 0;
}
  • 可以看到如果在父线程程序中出现异常导致局部变量销毁析构的话,就会调用thread_guard的析构方法,而其析构方法在确保thread实例关联线程的情况下就会将线程与其分离或汇合若未抛出异常,程序执行到末尾正常销毁析构局部变量,也会执行正确的逻辑
  • 要注意,在调用thread的join和detach前都必须先检查其是否关联了线程即当joinable()返回ture时才能调用join和detach函数否则是错误行为
  • 上述代码将复制构造函数和复制赋值操作符都以"=delete"标记,为的是令编译器不得自动生成相关代码,因为这类对象的复制或赋值都有可能带来问题比如对同一线程的两次析构,将重复调用join函数。如果新线程是设计与父线程分离的,可以尽早分离,从而防止异常引发的安全问题
  • 分离线程后还能实现一套机制用于确认线程是否完成运行
  • 除了汇合分离两种方法之外,如果需要更精细的控制线程,如查验线程结束与否,或只限定等待一段时间,便需要用到其他方式,如条件变量和future,后面将会介绍这些内容

2.2 向线程函数传递参数

(1)简单参数的传递

  • 如果需要向新线程上的函数或可调用对象传递参数,只需依次向std::thread的构造函数中增添参数即可。
  • 示例如下:
#include <iostream>
#include <thread>

// 一个带参的函数
void print(std::string i)
{
	std::cout << i << std::endl;
}

// main线程
int main()
{
	std::string str = "Learn DirectX So Cool!";

	// 构造thread对象并传入可调用对象以及其参数,线程就被发起
	std::thread t(print,str);

	// 等待子线程
	t.join();

	return 0;
}
  • 运行结果:
    在这里插入图片描述
  • 需要注意的是,线程具有内部空间,参数会按照默认方式复制到此处,只有新创建的线程才能直接访问它们。

(2)传递参数时注意事项

  • 需要注意的是无论可调用对象中的参数类型是否为引用,提供的实参将被当作临时变量,以右值的形式传递给新线程上的可调用对象。
  • 当可调用对象的的参数为引用时,向其传递参数的错误示例代码如下:
#include <iostream>
#include <thread>

// 带有引用参数的可调用对象
void print(int& i)
{
	std::cout << i << std::endl;
}

// main线程
int main()
{
	int i = 0;
	// 向构造函数中传入可调用对象和对应参数
	std::thread t(print, i);

	t.join();
	return 0;
}
  • 但是上述代码是错误的,会编译失败报错如下:
    在这里插入图片描述
  • 如果将可调用对象中的T&类型改为const T&类型,则程序可以编译成功。示例代码如下:
#include <iostream>
#include <thread>

// 可调用对象的参数类型为const int&
void print(const int& i)
{	
	// 在新线程中查看其地址
	std::cout << &i << std::endl;
}

// main线程
int main()
{
	int i = 0;

	std::thread t(print, i);// 发起新线程

	std::cout << &i << std::endl; // 在main线程中查看变量地址
	
	t.join();
	return 0;
}
  • 运行结果如下:
    在这里插入图片描述
  • 可以看到编译成功,但是两个变量的地址并不相同。
  • 总结:std::thread类的构造函数接受可调用对象的参数时,仅接受右值形式的参数,即仅接受固定不变、无法赋值和提取地址的实参,对于T&引用类型实参无法直接传入。

(3)std::ref和std::move

  • 上文说到我们无法直接传入T&实参,这是因为线程库的内部代码会把参数的副本以右值的形式传递,而可调用对象仅接受非const&类型而非右值类型,导致无法调用可调用对象,造成程序编译失败。
  • 那么就没有办法向线程传递引用类型参数了吗?并不是的,使用std::ref即可以引用方式传递参数。示例代码如下:
#include <iostream>
#include <thread>

void print(int& i)
{	
	// 在新线程中查看其地址
	std::cout << &i << std::endl;
}

// main线程
int main()
{
	int i = 0;

	std::thread t(print, std::ref(i));// 发起新线程

	std::cout << &i << std::endl; // 在main线程中查看变量地址
	
	t.join();
	return 0;
}
  • 运行结果如下:
    在这里插入图片描述
  • 可以看到程序编译成功,并且引用传递成功,因为不同线程中该变量的地址一致。
  • std::ref包装变量,使得传入可调用对象的参数不是i的副本,而是指向变量i的引用,代码因此能成功编译。
  • 还需要注意的是对于类内部的成员函数,由于其隐含了一个this作为参数,因此将类内成员函数作为可调用对象构建thread对象以及线程的代码比较特殊,示例如下:
#include <iostream>
#include <thread>

class X
{
public:
	int i = 1;
	void print()	// 类内成员函数
	{
		std::cout << i << std::endl;
	}
};

void test()
{
	X my_x;
	// 以类内成员函数为可调用对象,需要传入成员函数指针和类实例地址
	std::thread t(&X::print, &my_x);
	t.join();
}

// main线程
int main()
{
	test();
	return 0;
}
  • 运行结果如下:
    在这里插入图片描述
  • 可以看到当向std::thread类的构造函数传入可调用对象时,我们使用的是一个函数指针,且我们给出了类实例的地址或者说指针即&my_x,并将类实例指针作为构造函数的第二个参数。如果欲调用的成员函数拥有多个参数,那么在类实例指针后依次提供对应参数即可。
  • 还有一种情况需要注意,那就是从C++11开始引入了另外一种传递参数的方式:参数只能移动但不能复制,即数据从某个对象转移到另一个对象内部,而原对象则被搬空。如果std::unique_ptr类型,那么当可调用对象的参数为这种只能移动的参数时,你如何在std::thread对象的构造函数里为其提供参数呢?
  • 错误示例代码如下:
#include <iostream>
#include <thread>

void print(std::unique_ptr<int> ip)
{
	std::cout << *ip << std::endl;
}

// main线程
int main()
{
	std::unique_ptr<int> ip;
	std::thread t(print, ip);
	return 0;
}
  • 上述代码无法通过编译,因为我们无法将main程序中的ip赋值给可调用对象print参数列表中的参数ip。正确示例如下:
#include <iostream>
#include <thread>

void print(std::unique_ptr<int> ip)
{
	std::cout << ip.get() << std::endl;
}

// main线程
int main()
{
	std::unique_ptr<int> ip;
	std::thread t1(print, std::move(ip)); // 使用std::move
	std::thread t2(print, std::unique_ptr<int>()); // 使用临时变量

	t1.join();
	t2.join();
	return 0;
}
  • 由于只能移动不能复制,因此我们需要使用std::move来为可调用对象提供参数,当参数是临时变量时移动会自动发生,因此也可以正常运行。
  • 要牢记thread构造函数会先将构造函数中的参数以右值形式复制到新创建线程的内部存储空间,然后再自行将其赋值或转换为可调用对象的参数类型变量,对于任何变量类型都不例外。(创建线程时会将所有参数复制到线程库内部存储)
  • 一般地,在调用类的非静态成员函数时,编译器会隐式添加一参数,它是所操作对象的地址,用于绑定对象和成员函数,并且位于所有其他实际参数之前。

(4)注意自动类型转换

  • 我们说过要注意指针和引用类型,在新线程中访问的它们可能是不存在的。
  • 当我们构造一个thread对象时,我们需要传入可调用对象的对应参数,而构造函数执行完毕仅保证:将构造函数中参数以其自身类型的右值复制到线程内部的存储空间中。却不能保证可调用对象的对应参数类型已经转换完毕。
  • 示例代码如下:
void f(double a);
void test()
{
	int i = 0;
	std::thread t(f,i);
}
  • 可以看到可调用对象f的参数是double类型,但在test函数中我们可以使用整形变量i作为参数构造std::thread对象,这是因为int类型可以自动转换为double类型。当程序执行std::thread t(f,i)时,就会将i变量的值以右值形式复制到线程内部的存储空间。当构造函数执行完毕后,线程内部可能再将i变量复制过来的值转换为一个double类型变量。
  • 对于以下程序,依旧会发生自动类型转换,但可能出错:
void f(std::string s)void test()
{
	char buffer[1024];
	std::thread t(f, buffer);
}
  • 上述代码可调用对象f的参数类型为string,但构造函数中传入的参数类型为char*,于是当构造函数执行完毕,仅保证将char* 类型变量buffer的值复制到了线程内部,我们都知道那只是个指针,要变为string类型还需要额外的转换。但这时test函数很可能已经执行完毕,并正常的释放了局部变量buffer数组中的内存,而新线程才刚刚发起,正准备使用复制过来的char* 变量转换为string类型,但char* 变量指向内存已经被释放,从而导致错误!
  • 如果构造函数中参数的类型和可调用对象参数列表中类型逐个对应的话,那么在构造函数执行过程中,就会把构造函数中的参数以右值形式依次复制到线程内部存储空间,此时构造函数执行完毕,线程再把复制到内部的值依次转换为自己的变量即可。
  • 但如果构造函数中参数的类型和可调用对象参数列表中类型不同的话,要使程序能正常运行,就需要保证构造函数中参数的类型可以自动转换为可调用对象参数列表中的对应类型。而线程仅会按构造函数中参数的类型对变量进行复制,在构造函数结束后才可能会将复制的变量转换为可调用对象中使用的参数类型。
  • 总结:当构造函数中参数类型与可调用对象参数列表中参数类型不一致时,尽量修改构造函数中参数类型,将其改为可调用对象参数列表中的参数类型,或者手动显示转换其为可调用对象参数列表中的参数类型。
  • 针对上例错误代码进行修正:
void f(std::string s)
{
	std::cout << s << std::endl;
}

void test()
{
	char buffer[1024];
	std::thread t(f, std::string(buffer));	// 手动显式转换
}
  • 可以看到在上述代码中,我们手动显示转换了thread构造函数中的实参,使其类型和可调用对象参数列表中一致,这样复制到线程内部的就是string变量而非char*变量,构造函数结束后新线程内无需进行额外类型转换,也因此避免了自动类型转换的风险。

2.3 移交线程归属权

(1)使用移动语义移交线程

  • std::thread类和std::unique_ptr类有共同之处,std::thread类的实例也只能移动而不能复制,因为每个实例可能负责管控一个执行线程,这就保证了对于任意特定的执行线程,任何时候只有唯一的std::thread对象与之关联,并且还准许程序员在不同实例间转移线程的归属权。
  • 无法将一个std::thread实例直接赋值给另一个,如下错误示例:
    在这里插入图片描述
  • 而只能通过移动语义来移交线程的归属权:
    在这里插入图片描述
  • 要注意std::thread()返回的实例不关联任何线程。
  • 如果一个std::thread实例已经关联了线程,那么就不能再向他移交其他线程的归属权,否则会调用std::terminate函数导致整个程序终止,错误示例如下:
#include <iostream>
#include <thread>

void print()
{
	std::cout << 1;
}

// main线程
int main()
{
	std::thread t1(print),t2(print);
	t1 = std::move(t2);			// 错误,向已关联线程实例显式移交线程归属权
	t1 = std::thread(print);	// 错误,隐式移动
	return 0;
}
  • 因此在向std::thread实例移动线程归属权前,必须确保其未关联线程。
  • 因此对于函数内返回std::thread对象,直接返回即可,正确示例如下:
std::thread getThread()
{
	std::thread t;
	return t;
}
  • 对于需要向函数中传递std::thread对象,直接使用移动语义即可,正确示例如下:
#include <iostream>
#include <thread>

void setThread(std::thread t)
{
	if(t.joinable())t.join(); // 汇合和分离前都必须检查joinable()
}

// main线程
int main()
{
	std::thread t1;
	setThread(std::move(t1)); // 显式移动
	setThread(std::thread()); // 隐式移动
	return 0;
}
  • 移动语义确保了一个线程最多被一个std::thread实例关联。

(2)设计简单线程封装类

  • 一个简单的线程封装类如下:
class scoped_thread
{
	std::thread t;
public:
	explicit scoped_thread(std::thread t_) :
		t(std::move(t_))
	{
		if (!t.joinable())
			throw std::logic_error("No thread");
	}
	~scoped_thread()
	{
		t.join();
	}
	scoped_thread(scoped_thread const&) = delete;
	scoped_thread& operator=(scoped_thread const&) = delete;

};
  • 这个线程类的主要功能是:保证了在离开该类实例作用域之前,该类关联的线程已经完结。
  • 在析构函数直接调用了join,是因为构造函数中我们已经要求thread实例t必须关联线程,并且t对外是不可见的。
  • 因为std::thread支持移动语义,因此只要容器支持元素移动意图,那么就可以使用容器存储std::thread对象。如使用std::vector<>存储多个线程,并等待它们的结束,示例代码如下:
#include <iostream>
#include <thread>
#include <vector>	 // 使用vector容器
#include <Windows.h> // 使用windows上的高性能计数器来计时

// 构建一个高性能计时器
class Time
{
public:
	double mSecondsPerCount;	// 存储每个CPU计数所用秒数
	__int64 startTime;			// 存储开始计时时CPU已经运行计数
	
	Time()	// 在构造函数中获得用户每个CPU计数所用秒数
	{
		__int64 countsPerSec;
		QueryPerformanceFrequency((LARGE_INTEGER*)&countsPerSec); // 获取性能计时器的频率
		mSecondsPerCount = 1.0 / (double)countsPerSec;			  // 计算每个计数包含的秒数
	}

	void start()	// 开始计数,将startTime更新为此时CPU已经过计数
	{
		QueryPerformanceCounter((LARGE_INTEGER*)&startTime);
	}

	double getTime() // 获取从开始计数到此时所经过的时间秒数
	{
		__int64 nowPerSec;
		QueryPerformanceCounter((LARGE_INTEGER*)&nowPerSec);	// 获取此时CPU计数

		// 经过时间 = (现在的计数-开始时计数) * 每计数包含秒数
		return (nowPerSec - startTime) * mSecondsPerCount;      
	}
};

// 测试性能程序,使整形变量自增c次
void test(int c)
{
	for (int i = 0; i < c; ++i);
}

// main线程
int main()
{
	// 定义计时器、每个线程所要测试的规模数、存储关联线程对象的容器vector
	Time time;
	int c = 100000000;
	std::vector<std::thread> threads;
	
	// 开始计时
	time.start();
	// 在容器中创建10个线程,每个线程执行c规模的测试程序
	for (int i = 0; i < 10; ++i)
	{
		threads.emplace_back(test, c);
	}
	// 等待所有子线程结束
	for (int i = 0; i < 10; i++)
	{
		threads[i].join();
	}
	// 打印多线程执行共10c规模的测试程序所需时间
	std::cout << time.getTime() << std::endl;

	// 重载计时器,开始计时
	time.start();
	// 令main线程独立执行10c规模
	test(10 * c);
	// 打印main线程单独执行10规模的测试程序所需时间
	std::cout << time.getTime() << std::endl;

	return 0;
}
  • 运行结果如下:
    在这里插入图片描述
  • 可以看到多线程对于性能的提升显著。当然你可以尝试修改c的大小,当c增大或缩小时,多线程的性能可能会降低,甚至低于单线程。
  • 当C较小时,多线程启动会耽误更多的时间,导致不如单线程的执行速度块。当逐渐增加C的大小,随着运算规模的提升,多线程的性能越来越高,直至超过单线程数倍。当C的大小往亿以上增长的更大时,多线程的性能逐渐下降,逐渐甚至不如单线程,在这时如果减少线程数量,我们就能发现多线程的所用时间减少了。
  • 因此并非线程数量越多越好,也并非线程数量越少越好,一切都要根据任务的工作量决定。根据任务的工作量选取对应数量的线程数,才能发挥多核CPU的性能。
  • 如果将总任务切分为多个子任务,为保证总任务的完成,必须等待每个子任务汇合。线程的某些操作可能影响其他线程的效率,产生多线程的副作用,这些操作都是执行状态改变的操作,如:访问volatile变量、修改对象、进行I/O访问、调用某个会造成副作用的函数等。

2.4 在运行时选择线程的数量

(1)线程数量和应用程序性能的关系

  • 为什么要在运行时选择线程的数量?因为只有运行时才能知道用户的硬件状况,因地制宜。因为运行时用户系统的环境可能多种多样,比如开了好几个高性能应用程序,又或者什么程序都没有运行CPU性能大大剩余。
  • 我们知道每个CPU核心有它自己的频率,因此每个CPU核心在一定时间内最多能做的工作是一定的。当我们不使用多线程技术时,程序最大的运行速率就是单核满负载运行,那么我们或者说用户的其他多个核心就浪费了,比如现在的拯救者笔记本电脑搭载intel i9包含32个核心,如果不使用多线程技术不就浪费了31个核?
  • 当然需要注意的是,现在的计算机系统线程数成百上千,基本都大于CPU核心数,因此本质还是要任务切换。如果不使用多线程,虽然享受不到其他核心,但其他核心帮助计算其他任务,使得程序使用单核心性能的占比更大。
  • 当线程数量较低时,我们无法使用到CPU多核心的计算能力,比如对于8核心CPU,7个线程永远用不到完全的性能,虽说剩下一个核心也会帮助计算其他任务。那么是不是8个核心就好了呢?这只是基础,因为毕竟其他任务会和我们的程序抢占CPU时间片,如果增加到10个线程或者更大,我们可能会抢到更多的时间片,使得我们应用程序的总体性能提升。那么是不是线程数目越多越好呢?如果线程数量过多,线程之间来回切换导致的巨大的切换浪费,可能导致CPU和应用程序的性能都下降。
  • 综上所述:线程太少无法运行在CPU多个核心上,或者无法得到更多的CPU时间片,因此应用程序性能降低。线程太多导致任务切换过多,会降低CPU整体的运算能力,也会导致应用程序性能降低。
  • 因此我们都需要在程序运行时,根据硬件和系统的实际情况,选择合适的线程数量,以运行最高性能的应用程序。

(2)如何选择线程数量

  • 使用C++标准库的std::thread::hardware_concurrency函数,可获取一个指标,表示程序在每次运行中可真正并发的线程数量。在多核系统上,该值可能就是CPU的核芯数量。但它仅作为一个参考,若信息无法获取,该函数则可能返回0。

2.5 识别线程

  • 线程ID使用std::thread::id表示,它有两种获取方法。
    1.若std::thread实例关联某线程,则调用实例的成员函数get_id函数即可得到该实例关联线程的ID。若实例未关联线程,调用get_id则会返回一个std::thread::id对象,它按照默认构造方式生成,表示“线程不存在”。
    2.运行程序的线程可通过在程序中调用std::this_thread::get_id函数获取,该函数定义位于头文件< thread >中。
  • 获取程序ID的示例代码:
#include <iostream>
#include <thread>

// main线程
int main()
{
	std::cout << std::this_thread::get_id() << std::endl;
	std::thread t;
	std::cout << t.get_id();
	return 0;
}
  • 运行结果:
    在这里插入图片描述

  • std::thread::id类型支持复制操作和比较运算,当两个std::thread::id类型的对象相等时,则它们代表相同的线程,或者它们的值都表示线程不存在。如果不相等,则它们代表不同的线程,或者一者表示线程而另一者表示线程不存在。

  • 由于std::thread::id具备全套完整的比较运算符,因此它可以作为关联容器的键值,或者用于排序等其他比较用途。

  • 因此std::thread::id可以用作新标准的无序关联容器的键值。

  • 我们可以在数据结构中存储std::thread::id类型成员,以此保存当前线程的ID,并且将其作为操作的要素以控制权限。比如设置状态-线程ID结构体,调用对象中检查此结构体中的状态,从而判断应当执行哪些操作,并实现检查操作是否合法等功能。

  • 在某些情况下,我们需要将一些数据与线程相关联,但使用如 “将数据存储在线程的局部数据” 等方式却并不合适,这时就可以采用关联容器,以线程ID作为键值,存储每个线程相关联的数据。主线程还可利用此种方式存储每个受控制线程的相关信息,或存储信息以便在线程间相互传递。

  • 绝大多数情况下,不需要额外定义线程ID,除非是把ID作为数组索引等方式使用。

文章来源:https://blog.csdn.net/qq_51563654/article/details/134854268
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。