C语言多线程编程细节的重要性

Author Avatar
LittleBlack 4月 07, 2020
  • 在其它设备中阅读本文章

我没有专门学习过多线程,非常可能有更简单的方法,我说了一大堆废话只是绕了远路。不过这次尝试中应该还是有很多自己的感悟的,所以如果你是大神,觉得很滑稽,这个家伙写得都是什么垃圾啊,笑一笑就好啦 : ) 如果你是和我一样的小白,欢迎共勉共同进步。

想要实现一个Text UI (我对命令行情有独钟,因为我做不出图形界面) ,要控制光标同时绘制多个区域以及时响应。那么这个就很明显用到多线程了,奈何我对多线程一窍不通,于是…

  • 这是我理想中的窗体:

image-20200407165516434

  • 实际画出来的效果是这样的:

image-20200407165616379

这一坨坨条形码,让我顿时感到世界对自己充满了恶意…

第一次排错

这个其实很明显。控制台的标准输出就一个,多个线程控制着光标满屏乱跑,A线程抱着光标钻到草丛里还没干事请呢就被B线程横刀夺爱,最后搞出来的东西自然是谁都不像。

我于是用了一个队列,思路是这样的:

  • 新建一个队列q,函数queLock()、函数queUnlock()、Bool类型变量queueLock
  • 当A函数想要执行敏感操作(比如修改同一个变量)的时候,就去使用queLock()。这个函数会给它添加一个标识到队列尾部。然后不断检查队列头和queueLock,如果queueLock变成false且队头轮到这个标识符了,就瞬间取出这个标识符然后锁上queueLock
  • 当敏感操作完毕后,用queUnlock取消queueLock的锁。
  • 这就相当于queLock()暂时锁住了这个线程。虽然降低了部分效率,但是还是可以体现多线程的优势的。

我的代码是这样的。

bool queueLock=false;
int ident=0;
queue<int> q;
inline void queLock(){
    int id=++ident;
    q.push(id);
    while(1){
        if(!queueLock&&q.front()==id){
            queueLock=true;
            q.pop();
            return;
        }
    }
}
inline void queUnlock(){
    queueLock=false; //inline会在编译的时候直接插入代码,因此无需担心调用费时。
}

这应该解决了吧?

然鹅…

基本没有变化!

图样图森破啊!

第二次排错

经过研究,我终于发现了问题所在。如果你正在看(且你不是我),你估计早就发现问题了。

int id=++ident;

这个完美无缺的函数第一行就出现了问题。

这个操作先给ident+1,然后将此时的ident赋值给id。这个连在一起写可能看不出来,那么我分开。

//上面的代码等价于:
ident++;
int id;
id=ident;

这就很明显了吧。另外一个线程完全有可能在前两行或者后两行中间横插一腿。

image-20200407172356043

所以这个id放在这里就是个花瓶。等于把问题从stdout的访问冲突转嫁到了ident的访问冲突上。确实访问效率更高了,但是没有从根本上解决问题。

解决方案其实也很简单。关键在于ident访问冲突,这个ident是干什么的啊?用来区别线程的。区别线程我干嘛要专门弄一个标识符,这不是画🐍添足吗??

直接上改正过的代码了。

bool queueLock=false;
queue<HANDLE> q;
inline void queLock(HANDLE hThread){ //排队+加锁
    q.push(hThread);
    while(1){
        if(!queueLock&&q.front()==hThread){ 
            queueLock=true;
            q.pop();
            CloseHandle(hThread); 
            return;
        }
    }
}
inline void queUnlock(){
    queueLock=false;
}

经过这次调整,效果很明显。发生问题的几率降低了50%,错乱也含蓄了很多,从大块错乱变成了标题移位。

image-20200407173144088

还是不给力啊!百分之五十算个毛线??标题移位算个毛线???

第三次排错

经过又一翻的 苦(拔) 思(秃) 冥(头) 想(发) ,终于OK了。

仔细看下第二次的代码,先给你一炷香的时间思考那里有问题。

image-20200407173643248



想出来了吗?

没有?

不聪明啊!看看标题,细节啊!细节决定成败啊!

image-20200407174142191

这个if语句看起来与世无争,属于谁都不会去考虑的类型,在一般的程序中是绝对没有任何问题的。但是这是多线程编程,即使代码写的再紧凑,每条语句中间还是有延迟的。

上面的if语句可以拆解成下面的语句:

//与上面的语句等价
if(!queueLock){  //第一个判断 
    if(q.front()==hThread){ //第二个判断
            queueLock=true;
            q.pop();
            CloseHandle(hThread); 
            return;
    }
 }
inline void queUnlock(){
    queueLock=false;
}

那么,如果甲线程在执行第一个判断的时候,丙线程刚刚执行完毕,把queueLock给取消掉了。这个时候甲和乙齐头并进,都完成了第一重判断。这个时候甲线程率先完成第二重判断并pop掉了自己,于是乙再次完成第二重判断,和甲线程一起进入了多线程状态。
(这里的乙可以换成任意一个非甲线程,丙丁戊己庚辛 随便那个都可以。在这个程序里面肯定有一堆线程等着要出这个头。)

image-20200407200758144

解决方案就是,把if中的两个条件调换一下顺序。理解了BUG存在的原因,那么怎么去掉他就非常容易了。

image-20200407201300181

代码我就不贴了,直接贴图我方便,你看着也方便。

那么这样运行出来之后就稳定的得到了本文的第一张图,多次测试没有变过,应该是成功了。

现在可以来验证一下我的猜想,到底是不是这样。

如果我的猜想成立的的话,那么这个BUG发生的条件是”同时存在三个及以上的线程“。我于是去掉了一个线程(框框)反复尝试,确实,一直都没有发生问题。

image-20200407180359819

为什么出现错误时只有标题移位我还没有搞清楚。我猜想是系统提供的输出函数会使用自己的方法去后移坐标位置,方法应该比我的SetCursorPosition()更加基础,也更快一点。这种速度跟我的慢速冲突了,所以系统的输出争先恐后的涌到前面来导致了错乱。

总结

在这个程序里我完成的事请事实上是在特定条件下把并行改为并发,用了队列的这个结构。队列中其实同时存在的元素上限恒等于同时存在的进程数上限。这个是使用了队列结构的特性。

第一个问题(第零次排错)的发生,只是用来作为本文的开头提出问题的。可以对问题有个直观认识,我毕竟还没蠢到那个程度。

第二个问题(第一次排错所引发的)是没有认清楚解决问题的本质。我实际上干了转移问题的操作,而没有从实质解决问题。问题从进程抢光标变成了进程抢标识符。

第三个问题(第二次排错所引发的)是没有细节。多线程编程是很讲究细节的,if判断还是建议分开来写,不然大大延长出现bug后拔头发的时间。

而且多线程编程还是对调试不太友好的(特别是命令行情况下)。你不能开一个调试窗格去搞它,你一开,结果又不一样了。你不能去增大延时去仔细看输出顺序,因为你开延时之后就没问题了。这个有点像薛定谔的猫,开箱之后你最终只能看到猫的死活,你看不到猫是怎么死掉的。所以处理这种问题的时候还是建议自己开思维导图逐行的推断,给自己大脑编个码,也许问题就解决了。

后话

刚刚发现,手动的一遍遍稳定性测试弱爆了….

其实如果真的要测试到底有没有问题,写一个线程不停抢资源就是了。

void cpu_eater(void*){
    while(1){
        queLock(getHandle());
        queUnlock();
    }
}
int main(void)  
{  
    _beginthread(win_playlist,0,NULL);
    _beginthread(win_menu,0,NULL); 
    _beginthread(win_progressbar,0,NULL);
    //下面是抢资源线程
    _beginthread(cpu_eater,0,NULL);
    _beginthread(cpu_eater,0,NULL);
    _beginthread(cpu_eater,0,NULL);
    //上面是抢资源线程
    sleep(LIFETIME_DELAY);
    return 0;  
}

一旦有问题,在三个线程不停跟他抢资源的情况下肯定立即就暴露出来了。这样可以非常方便的一遍检出问题。我刚刚写完才发现有此等操作,真是冤煞我的Ctrl+F5。

下面是开了三个抢资源线程的动图。

录制_2020_04_07_20_36_41_935.gif

还蛮美观的蛤。(这就完全没问题了)

思考题

留一道思考题。

如果你自信彻底理解透了你就试试看这道题。

上面的代码我通过修正判断的顺序解决了问题。但是我如果不去修正判断顺序,而是修正执行顺序,即:

image-20200407174925544

调换这两行顺序,能不能一样达成目的呢?

(偷偷透露下,不行。)

那么为什么不行呢?

答案:image-20200407183057649

本博客使用CC BY-NC-SA 3.0创作协议,转载请注明出处。
本文链接:https://www.hackblack.cn/posts/10TTZ3B/