创建多个线程、数据共享问题分析与案例代码
创建和等待多个线程
在实际的工作中,可能要创建的线程不止一个,也许有多个。所以,这里展示一下创建多个线程的一种写法,大家可以举一反三。
在lesson4.cpp
的上面位置,书写线程入口函数 myprint
:
void myprint(int inum)
{
cout << "myprint线程开始执行了,线程编号=" << inum << endl;
// 干各种事情
cout << "myprint线程结束执行了,线程编号=" << inum << endl;
return;
}
在main
主函数中,加入如下代码:
vector<thread> mythreads;
// 创建5个线程。当然,线程的入口函数可以用同一个,这并没什么问题
for (int i = 0; i < 5; i++)
{
mythreads.push_back(thread(myprint, i)); // 创建并开始执行线程
}
for (auto iter = mythreads.begin(); iter != mythreads.end(); ++iter)
{
iter->join(); // 等待5个线程都返回
}
cout << "main主函数执行结束!" << endl; // 最后执行这句,然后整个进程退出
执行起来,看一看结果(由于多个线程无序输出,结果看起来比较乱):
myprint线程开始执行了,线程编号=myprint线程开始执行了,线程编号=2
myprint线程结束执行了,线程编号=2
myprint线程开始执行了,线程编号=3
myprint线程结束执行了,线程编号=3
myprint线程开始执行了,线程编号=1
myprint线程结束执行了,线程编号=1
0
myprint线程结束执行了,线程编号=0
myprint线程开始执行了,线程编号=4
myprint线程结束执行了,线程编号=4
main主函数执行结束!
从结果可以看到:
- 多个线程之间的执行顺序是乱的。先创建的线程也不见得就一定比后创建的线程执行得快,这个与操作系统内部对线程的运行调度机制有关。
- 主线程是等待所有子线程运行结束,最后主线程才结束,所以推荐
join
(而不是detach
)写法,因为这种写法写出来的多线程程序更容易写得稳定、健壮。
- 把
thread
对象放到容器里进行管理,看起来像一个thread
对象数组,这对一次性创建大量的线程并对这些线程进行管理是很方便的。
数据共享问题分析
只读的数据
一段共享数据,如有一个容器,这里说一说容器里面的数据。如果数据是只读的,每个线程都去读,那无所谓,每个线程读到的内容肯定都是一样的。
例如有一个全局的容器:
vector<int> g_v = {1, 2, 3};
在 main
主函数中依旧是上面这样创建5个线程,每个线程都打印容器g_v
中的元素值, main
中代码不需要修改,只需要修改线程入口函数myprint
中的代码。修改为如下:
void myprint(int inum)
{
cout << " id 为" << std::this_thread::get_id() << "的线程打印g_v值" << g_v[0] << g_v[1] << g_v[2] << endl;
return;
}
执行起来,虽然结果看起来比较乱,但其实程序执行的是稳定和正常的。
有读有写
事情坏就坏在有读有写上了,如创建了5个线程,有2个线程负责往容器里写内容,3个线程负责从容器中读内容,那这种程序就要小心谨慎地写,因为代码一旦写不好就容易出问题,或者换句话说,如果写的代码不对,肯定出问题。
最简单的处理方式是:读的时候就不能写,写的时候就不能读,两个(或者多个)线程也不能同时写,两个(或者多个)线程也不能同时读。
请细想一下,这件事情不难理解,比如说写,写这个动作其实有很多细节步骤,如分10步,如第1步是移动指针,第2步往指针位置处写,第3步⋯⋯,那这10步是一个整体,必须从头到尾把10步全做完,才能保证数据安全地写进来,所以必须用代码保证这10步都一次做完,如果写这个动作正做到第2步,突然来了个读,那可能数据就乱套了。可能正好就读到了正在写还没写完的数据等,那么,各种诡异和不可预料的事情就会发生,一般的表象就是程序会立即运行崩溃。
其他案例
这种数据共享的问题在现实生活中随处可见。例如卖火车票,若这趟火车从北京到深圳,卖这趟火车车票的售票窗口有10个(1~10号),如果1号和2号售票窗口同时发出了定20号座位票的动作,那么肯定不能这两个窗口都订成功这张票(一个座位不可能卖给两个人),肯定是一个窗口订票成功,另一个窗口订票失败。那么,订票这个动作至少要分成两个小步骤:
- 订票系统要先看这个座位是否已经被其他人订了(这是一个读操作),如果订过了,订票系统就直接告诉售票窗口“订票失败”,如果这个座位没被订,就继续进行下面的第二步。
- 订票系统帮助售票窗口订这个座位的票、设置这个座位的状态为已经被订状态、记录被哪个售票窗口所订、订的时间等(这是一个写操作),然后返回订票成功信息给售票窗口(注意这一步中的所有这些动作都要一次完成,中间不能被截断)。
那请想一想,1号售票窗口订票的时候,其他售票窗口想订票,那也必须得等着,等1号售票窗口做完了订票这个动作之后,其他售票窗口才能继续订票,否则,就有可能两个人都订到了20号座位(同一个座位)的票,那这两个人就得打架了。
共享数据的保护实战范例
这里举一个实际工作中能够用到的范例来讲解共享数据的保护问题。
就以一个网络游戏服务器开发为例来说明。这里把问题简化,假设现在在做一个网络游戏服务器,这个网络游戏服务器程序包含两个线程,其中一个线程用来从玩家那里收集发送来的命令(数据),并把这些数据写到一个队列(容器)中,另一个线程用来从这个队列中取出命令,进行解析,然后执行命令对应的动作。
这里就假设玩家每次发送给本服务器程序的是一个数字,这个数字就代表一个玩家发送过来的命令。
定义一个队列,这里使用list
容器。list
容器与vector
容器类似,只是list
在频繁按顺序插入和删除数据时效率更高,而vector
容器随机插入和删除数据时效率比较高。如果想更详细地了解list
容器,可以借助搜索引擎学习。
下面开始写这个服务器程序。
在lesson4.cpp
的开始位置增加如下#include
语句:
#include <list>
同时,这里准备使用类的成员函数作为线程入口函数的方法来书写线程。类A的定义如下(代码写在MyProject. cpp
文件的上面位置):
class A
{
public:
// 把收到的消息入到队列的线程
void inMsgRecvQueue()
{
for (int i = 0; i < 100000; i++)
{
cout << "inMsgRecvQueue()执行,插入一个元素" << endl;
msgRecvQueue.push_back(i); // 假设这个数字就是收到的命令,则将其直接放到消息队列里
}
}
// 把数据从消息队列中取出的线程
void outMsgRecvQueue()
{
for (int i = 0; i < 100000; i++)
{
if (!msgRecvQueue.empty())
{
int command = msgRecvQueue.front(); // 返回第一个元素但不检查元素存在与否
msgRecvQueue.pop_front(); // 移除第一个元素但不返回
// 这里可以考虑处理数据
// ……
}
else
{
cout << "outMsgRecvQueue()执行了,但目前收消息队列中是空元素" << i << endl;
}
}
cout << "end" << endl;
}
private:
std::list<int> msgRecvQueue; // 容器(收消息队列),专门用于代表玩家给咱们发送过来的命令
};
在main
主函数中,代码调整成如下的样子:
A myobja;
std::thread myOutnMsgObj(&A::outMsgRecvQueue, &myobja); // 注意这里第二个参数必须是引用(用//std::ref也可以),才能保证线程里用的是同一个对象(上一节详细分析过了)
std::thread myInMsgObj(&A::inMsgRecvQueue, &myobja);
myInMsgObj.join();
myOutnMsgObj.join();
cout << "main主函数执行结束!" << endl;
至此这个范例就写出来了。可以猜测一下,运行起来之后,会发生什么情况。
执行起来,看一看结果(这里可以多运行几次)。
可以发现,程序很可能运行几秒钟后就会报异常(程序运行处于不稳定状态),这表示程序代码写得有问题。
根据刚才讲解的数据共享问题理论,很容易分析到这个异常问题出在哪里。
inMsgRecvQueue
不断往队列中写数据,而outMsgRecvQueue
不断从队列中读取和删除数据。
这就叫作有读有写,如果程序员完全不控制,让这两个线程随意执行,那一定会出错,只是早一点出错或晚一点出错的问题。试想一个线程正在写还没写完,另外一个线程突然去读,或者去删除,还没删完,第一个线程又突然往里面写,这想都不用想,数据肯定乱套,程序肯定报异常。
明白了产生问题的原因,并不难想到解决问题的办法。
只要程序员能够确保inMsgRecvQueue
线程往队列里写数据的时候,outMsgRecvQueue
线程等待,等inMsgRecvQueue
写完数据的时候,outMsgRecvQueue
再去读和删除。或者换一种说法,只要程序员确保outMsgRecvQueue
线程从队列中读数据和删除数据时,线程inMsgRecvQueue
等待,等outMsgRecvQueue
读和删除完数据的时候,inMsgRecvQueue
再去写数据,那就保证不会出问题。
所以这里面我们看到了这个队列:
std::list<int>msgRecvQueue; //队列,也是容器
就是所说的共享数据,当某个线程操作该共享数据的时候,就用一些代码把这个共享数据锁住,其他想操作这个共享数据的线程必须等待当前操作完成并把这个共享数据的锁打开,其他线程才能继续操作这个共享数据。这样都按顺序和规矩来访问这个共享数据,共享数据就不会被破坏,程序也就不会报异常。
现在抛出了问题,并给出了解决这个问题的初步想法,那具体该怎样解决问题?如何把这个初步的解决问题的想法代码化呢?
这里引入C++解决多线程保护共享数据问题的第一个概念——互斥量。这是一个非常重要的概念,请现在开始强化记忆这个词。
代码地址