0xAA55 发表于 2019-4-24 21:48:31

【C】多线程与锁

多线程开发并不只是用CreateThread()、pthread_create()等函数创建了线程就可以随便玩了。事实上这里面大有玄机,设计失误会导致所有CPU核心被吃满的同时,实际性能却不如单线程,但却给了开发者一种“我把所有核心都利用上啦!”的错觉,最终引起整个运行系统性能降低。而且,这种事情其实是常有之事。

多线程开发是离不开锁的。什么是锁,为什么需要锁?我讲个故事。

有个人叫A,他是黑道上的,手下有几个杀手,B、C、D。其中A每天会把任务写到一个电线杆上,而B、C、D每天都会去看一下电线杆,看看任务是啥,然后去做,做完了就去忙自己的事(喝酒打游戏睡10小时)再去看电线杆,接新任务。

这里面我所说的A相当于主线程,B、C、D相当于工作线程。主线程给工作线程分配任务,而工作线程则负责完成主线程给的任务。在一开始,B、C、D所做之事很简单,就是看任务是啥,然后去做,做完了把任务标记为“已完成”。

有一天,A在电线杆上发布了新任务:杀死foo。C率先到达电线杆的位置,看到了任务,于是就跑去找foo了。随后B看到了电线杆,也去找foo了。最后D捂着宿醉后疼得难受的脑壳赶到了电线杆这儿,也看到了这个名为杀死foo的任务。然后B首先就找到了foo,嗯,他还是活着的。B大喊一声“我要代表牙膏厂消灭你!”然后拔出手枪把foo枪毙了。随后打算回去。而C和D则几乎同时找到了foo(的尸体),于是也喊了那句口号——然而因为C稍微快一点,所以俩人的声音交错重叠到了一起,变成了“我我要要代代表表牙牙膏膏厂厂消消灭灭你你!!”喊完后,C和D分别拔出手枪,各自朝着foo(的尸体)开了一枪后就回去了。不幸的foo被杀了三遍。

第二天,B赶到了电线杆处,然后在电线杆上写了个“已完成”就回去了。随后A赶到电线杆处,发现任务已经被完成了,就发布新的任务:杀死bar。然后就走了。但!因为C和D还没有在电线杆上写上“已完成”,所以C和D今天来电线杆这里并不是领任务,而是写“已完成”三个字上去(就像B做的那样)。结果,新任务“杀死bar”在没有被任何人接下的情况下,被写了两句“已完成”。A回来后就会认为“杀死bar”已经完成了,就会发布新的任务。

这就是典型的多线程开发上面会遇到的逻辑问题。在没有锁保护的时候,读取的变量的内容都是不可靠的。更何况对于更复杂的东西比如结构体的操作,一个线程写了一半,另一个线程却突然进来读出结构体。这样的情况,软件是不可能正常运行的。所以线程间的交互需要通过使用锁,来解决问题。

多线程概念的互斥锁,是一种用于保证同一个时间里,一些特定的操作必须被单个线程完成的过程的机制。之前提到的写一个结构体的过程,如果上了锁,在同一时间里只有一个线程可以进入这个锁,然后完成结构体的读写操作后,再退出锁。这样别的线程也才可以一个个排队进来处理。多线程交互的时候,锁可以保证关键性的操作能够被有序执行。

锁有很多种。最简单的锁是互斥锁,其次是把读写过程区分开的读写锁。读写锁允许多个线程一起读取一个数据,但如果你要更新这个数据,你需要使用读写锁的独占功能来占用这个锁。此时通过读的方式无法占用这个锁,但写的线程则可以在等到所有的读取者退出锁后更新数据。写的线程退出后,读的线程们就又可以通过读的方式进入锁,并同时读取数据了。

但是锁如果被使用得过多,也会导致性能的降低。如果一个线程无法进入锁,它就会进入等待状态,或者处理其它事物。而如果这个线程无事可做,它就只能等待锁被解开了。因此进行多线程开发的时候,要尽量减少线程间交互,也就是尽量让多个线程去做互相之间没有关系的工作,来减少等待锁的几率。锁用得过多的话,会导致多线程性能还不如单线程计算,却又要占用更多的资源。

此外锁的设计也是一个讲究的过程。要设计一个锁,你需要在它未锁住的时候锁住它,并防止别的线程打断你的操作。这个过程只能通过原子操作的方式来实现。

如果不使用原子操作,你会遭遇操作被打断的过程。典型例子:inc指令,或者C语言的x++这样的写法,看起来是“我一步就把一个变量的数值增加了1”,但其实不是。它根本不具备原子性。它在底层,其实是把变量读取后,加上1,再写回。如果两个线程同时进行这样的操作,我读取a,你读取a,我加上1,你加上1,我写回a,你写回a。最终a存储的数值并不能体现它同时被两个线程累加的效果,它只被加上了1,而不是被两次加上1,也就是增加2。这个过程叫“打断”。一个能被打断的操作是不能被当作可靠的原子操作来使用的。

除此以外,在使用原子操作进入锁失败的时候,应该进行什么样的处理,也是一个讲究的概念。原子操作进入失败的时候,你需要退避一个合适的时间来等待当前的占用者退出锁。而如果占用者占着茅坑不拉屎,你也不能干等,此时要从轻量级退避转换为重量级退避——比如放弃当前时间片,来释放CPU占用,让操作系统有余裕执行其它进程的处理。

事实上,多线程的锁的轮子虽然可以自己造,但这毕竟是一个性能讲究的东西,造不好会出事。不过尽管如此,在追求性能的时候,使用操作系统原生的互斥锁,并不是一个合适的做法。关键时刻依然需要自己微调自己的锁的行为。

多线程概念里,有一个叫“唤醒”的概念。比如你的多线程逻辑是主线程发布任务给子线程做,而子线程在无事可做的时候,会进入休眠状态来释放CPU占用。当你的主线程发布任务后,子线程结束休眠状态并进入工作的耗时,被称作“唤醒时间”。

通常是一个任务队列,里面描述了待完成的任务。主线程发布任务的时候,锁住任务队列的一个空项,设置它的内容,然后解除锁。性能好的锁的设计可以保证此时路过的子线程能立即响应并进入锁,然后处理任务队列单元描述的任务。

微软的SRWLock的性能非常理想,它甚至比微软自家的旧的单一功能的临界区性能更高。它具备很快的唤醒速度。可以逆向SRWLock的底层实现,然后自己实现一个跨平台的高效读写锁。

我写了一个自己的多线程库,是开源的。我实现了统一平台的原子操作库、带退避规则的互斥锁和读写锁,以及稍微跨平台的创建线程的函数和一个简单易用的任务队列功能。使用任务队列可以把一些简单的函数递交给线程池,让工作线程发现“单子”并“接单”。而发布单子的线程则可以检查任务的完成情况,并验收任务。当然有的任务也可以不被验收,发布后被执行后就释放任务池的占用。

我的多线程库发布到github上了。感兴趣的话可以试着clone一下,或者自己fork了拿去玩。有什么感想的话可以提一提issue。我近期都会处于活跃的状态。

https://github.com/0xAA55/mtutil

Golden Blonde 发表于 2019-7-30 23:35:41

太牛逼了完全看不明白。
页: [1]
查看完整版本: 【C】多线程与锁