最近在参加一些面试,其中遇到一道多线程打印的问题。当时做的不够好,后来回家自己又调了一下。这里把自己做的简单实现记录一下。
话不多说,题目大概是这样的:
有一个包含了字母,数字,特殊字符的字符串(假设为“~82hda@sf908r3jkrew&t92o435$”),要求设计3个线程,A线程打印其中的字母,B线程打印其中的数字,而C线程打印特殊字符,同时要求依次打印出行号,且行号不能乱。比如,
1.线程C打印出 ~
2.线程B打印出 8
3.线程B打印出 2
4.线程A打印出 h
…
当时我是这样写的
#include
#include
#include
std::string str = "~82hda@sf908r3jkrew&t92o435$";
std::mutex mutex_print;
void printLetter()
{
for(int i=1; i<=str.size(); ++i)
{
if ((str[i] >= 'a' && str[i] <= 'z') ||
(str[i] >= 'A' && str[i] <= 'Z'))
{
mutex_print.lock();
std::cout << i << ".printLetter " << str[i] << std::endl;
mutex_print.unlock();
}
}
}
void printDigit()
{
for(int i=1; i<=str.size(); ++i)
{
if (str[i] >= '0' && str[i] <= '9')
{
mutex_print.lock();
std::cout << i << ".printDigit " << str[i] << std::endl;
mutex_print.unlock();
}
}
}
void printOther()
{
for(int i=1; i<=str.size(); ++i)
{
if ((!(str[i] >= 'a' && str[i] <= 'z')) &&
(!(str[i] >= 'A' && str[i] <= 'Z')) &&
(!(str[i] >= '0' && str[i] <= '9')))
{
mutex_print.lock();
std::cout << i << ".printOther " << str[i] << std::endl;
mutex_print.unlock();
}
}
}
int main()
{
std::thread thread_A(printLetter);
std::thread thread_B(printDigit);
std::thread thread_C(printOther);
thread_A.join();
thread_B.join();
thread_C.join();
}
想法很丰满,现实很骨感!以为这样就能对打印顺序和字符做控制,简直就是异想天开。实际编译后运行,在thread_B.join();就抛异常了。
1.程序并不能保证按顺序打印
2.程序并未对临界区资源做真正的互斥操作
下面是我修改后的代码
#include
#include
#include
#include
const std::string str = "~82hda@sf908r3jkrew&t92o435$";
std::mutex mutex_print;
std::queue<char> queue_ch;
int line_num = 0;
void printLetter()
{
while (!queue_ch.empty())
{
auto str_ch = queue_ch.front();
if ((str_ch >= 'a' && str_ch <= 'z') ||
(str_ch >= 'A' && str_ch <= 'Z'))
{
mutex_print.lock();
queue_ch.pop();
std::cout << ++line_num << ".printLetter " << str_ch << std::endl;
mutex_print.unlock();
}
}
}
void printDigit()
{
while (!queue_ch.empty())
{
auto str_ch = queue_ch.front();
if (str_ch >= '0' && str_ch <= '9')
{
mutex_print.lock();
queue_ch.pop();
std::cout << ++line_num << ".printDigit " << str_ch << std::endl;
mutex_print.unlock();
}
}
}
void printOther()
{
while (!queue_ch.empty())
{
auto str_ch = queue_ch.front();
if ((!(str_ch >= 'a' && str_ch <= 'z')) &&
(!(str_ch >= 'A' && str_ch <= 'Z')) &&
(!(str_ch >= '0' && str_ch <= '9')))
{
mutex_print.lock();
queue_ch.pop();
std::cout << ++line_num << ".printOther " << str_ch << std::endl;
mutex_print.unlock();
}
}
}
int main()
{
for (const auto& item : str)
{
queue_ch.push(item);
}
std::thread thread_A(printLetter);
std::thread thread_B(printDigit);
std::thread thread_C(printOther);
thread_A.join();
thread_B.join();
thread_C.join();
}
这次主要改动在以下方面:
1.引入了queue,将字符串每个字符放到队列容器中,使其成为临界资源
2.每个线程循环判断队列是否为空,不为空时,获取队头字符
3.判断是否是自己这个线程要打印的字符
4.在pop队列队头字符的前后实现加锁和解锁操作
5.同时引入一个全局int型变量来记录行号
这次就实现了题目的功能。有人会说,如果每个线程加锁的地方放在if条件判断之前,是不是可以?答案是否定的!会出现下面的报错
这是什么原因呢?从异常说明可以看出,在线程调用front()的时候,队列已经为空了。可是明明是先判断的队列是否为空,如果已经为空了,while循环就退出了。
这就是多线程导致的问题。因为之前我们是先判断是否是自己线程需要打印的字符后,再加锁;这样就能保证其他两个线程虽然拿到了队首字符,但是不是自己线程要打印的字符,所以不用加锁。这样,它们就会一直循环判断直到真正处理队列的那个线程处理完,队列有了变化后,它们才有进入加锁处理的可能性。而现在的情况是,不管是不是我要打印的字符,我都先加锁。那这样就存在一种可能,虽然我判断队列不为空,但是另一个线程刚好在处理最后一个字符。然后当你调front时,那个线程刚好pop了。就报了异常。
继续思考。有时候我们在加锁后,会忘记释放锁导致抛异常。C++为我们提供了一个lock_grand的模板,它利用了RAII,可以自动释放锁。
于是代码可以变为下面这样
#include
#include
#include
#include
const std::string str = "~82hda@sf908r3jkrew&t92o435$";
std::mutex mutex_print;
std::queue<char> queue_ch;
int line_num = 0;
void printLetter()
{
while (!queue_ch.empty())
{
auto str_ch = queue_ch.front();
if ((str_ch >= 'a' && str_ch <= 'z') ||
(str_ch >= 'A' && str_ch <= 'Z'))
{
std::lock_guard<std::mutex> lg(mutex_print);
queue_ch.pop();
std::cout << ++line_num << ".printLetter " << str_ch << std::endl;
}
}
}
void printDigit()
{
while (!queue_ch.empty())
{
auto str_ch = queue_ch.front();
if (str_ch >= '0' && str_ch <= '9')
{
std::lock_guard<std::mutex> lg(mutex_print);
queue_ch.pop();
std::cout << ++line_num << ".printDigit " << str_ch << std::endl;
}
}
}
void printOther()
{
while (!queue_ch.empty())
{
auto str_ch = queue_ch.front();
if ((!(str_ch >= 'a' && str_ch <= 'z')) &&
(!(str_ch >= 'A' && str_ch <= 'Z')) &&
(!(str_ch >= '0' && str_ch <= '9')))
{
std::lock_guard<std::mutex> lg(mutex_print);
queue_ch.pop();
std::cout << ++line_num << ".printOther " << str_ch << std::endl;
}
}
}
int main()
{
for (const auto& item : str)
{
queue_ch.push(item);
}
std::thread thread_A(printLetter);
std::thread thread_B(printDigit);
std::thread thread_C(printOther);
thread_A.join();
thread_B.join();
thread_C.join();
}
好了,继续思考!其实std::string本身就是一种特殊的list。那么能不能不要借助queue呢?答案是肯定的。我又接着想了下直接处理string的办法,如下
#include
#include
#include
#include
std::string orgin_str = "~82hda@sf908r3jkrew&t92o435$";
std::string str = std::reverse(orgin_str.begin(),orgin_str.end());
std::mutex mutex_print;
int line_num = 0;
void printLetter1()
{
while (!str.empty())
{
auto str_ch = str.back();
if ((str_ch >= 'a' && str_ch <= 'z') ||
(str_ch >= 'A' && str_ch <= 'Z'))
{
std::lock_guard<std::mutex> lg(mutex_print);
str.pop_back();
std::cout << ++line_num << ".printLetter " << str_ch << std::endl;
}
}
}
void printDigit1()
{
while (!str.empty())
{
auto str_ch = str.back();
if (str_ch >= '0' && str_ch <= '9')
{
std::lock_guard<std::mutex> lg(mutex_print);
str.pop_back();
std::cout << ++line_num << ".printDigit " << str_ch << std::endl;
}
}
}
void printOther1()
{
while (!str.empty())
{
auto str_ch = str.back();
if ((!(str_ch >= 'a' && str_ch <= 'z')) &&
(!(str_ch >= 'A' && str_ch <= 'Z')) &&
(!(str_ch >= '0' && str_ch <= '9')))
{
std::lock_guard<std::mutex> lg(mutex_print);
str.pop_back();
std::cout << ++line_num << ".printOther " << str_ch << std::endl;
}
}
}
int main()
{
std::thread thread_A(printLetter1);
std::thread thread_B(printDigit1);
std::thread thread_C(printOther1);
thread_A.join();
thread_B.join();
thread_C.join();
}
这次的改进是
1.不借助queue了,直接对源字符串处理
2.因为发现string只有pop_back()成员函数,而没有pop_front(),所以先对源字符串反转,然后每次获取back()尾部字符处理
这样也是可以正确打印的
最后说几点
1.我也尝试不反转字符串,也还是每次front()首字符,然后把pop_back的地方,换成str = str.substr(1, str.size()),但是发现,在打印完第一个字符后,线程间死锁了,不再打印了,程序也不崩溃。我实在想不出原因,如果看到文章的大神希望能指点迷津
2.后来我又试了一种办法,就是再换成str.erase(0, 1),这样也能起到类似pop_front的效果。这次没有死锁了,但是程序会随机的抛异常,5次可能正常执行3到4次,抛异常1到2次。又是我想不通的地方,还是请大佬指点迷津
3.多线程的问题,还包括了很多,这里只针对这道面试题展开说说。不足之处,还请指出,欢迎技术交流。谢谢!