/首页
/开源
/关于
一万个进程的鬼故事 --- 多线程系列(三)
发表@2021-05-21 09:53:19
更新@2023-04-27 20:24:07
大家好,我是天天被白嫖却越被白嫖越爽、秃顶法师老赵养猪的御用兽医、东北大膘客的灵魂伴侣、早就进化且早就开始享受的高等文明、欧阳狂霸的第二面孔---谢顶道人老李。 在上篇文章发表后不到十分钟,就有一个泥腿子说:  大概意思就是他要给很多人推广告,然后为了推快点儿就一波儿开了一万个进程...一开始看好像还行,结果不一会儿服务器就归西了,于是赶紧灰溜溜改成了7000个进程,结果7000个进程也照样让服务器再次归西,全机房的服务器都机房门口集合,就等唢呐一响、炮仗一放、酒菜满上、坟头蹦迪。于是各界人士纷纷谴责:这也太TM不拿服务器当人看了,就别说996了,连007都没这个狠。  简直比西方人贩卖奴隶还惨无人道。 看来这位老哥急需云精通一把多线程,比如开一万个进程,然后每个进程中再开一万个线程,一个线程中再开一万个协程,这才算是充分利用服务器闲散资源,开归开,记得回收资源,不要提裤子不认人当渣男。 老李,你TM快别BB了,一篇文章正经篇幅一半都占不了,你是《三年进程,五年线程》做多了么? 线程通过pthread_create()可以轻松愉快地堆出来一大堆,那么线程如何退出呢? ```php #include
#include
#include
#include
void * thread_cb_one(void *); void * thread_cb_two(void *); int main(int argc, char *argv[]) { int pthread_ret; pthread_t thread_id_one, thread_id_two; void *pthread_exit_val; pthread_ret = pthread_create(&thread_id_one, NULL, thread_cb_one, NULL); if (pthread_ret != 0) { printf("创建线程失败:%d\n", pthread_ret); exit(pthread_ret); } pthread_ret = pthread_create(&thread_id_two, NULL, thread_cb_two, NULL); if (pthread_ret != 0) { printf("创建线程失败:%d\n", pthread_ret); exit(pthread_ret); } pthread_ret = pthread_join(thread_id_one, &pthread_exit_val); if (pthread_ret != 0) { printf("Join线程失败:%d\n", pthread_ret); exit(pthread_ret); } printf("pthread-one exit:%s\n", (char *)pthread_exit_val); pthread_ret = pthread_join(thread_id_two, &pthread_exit_val); if (pthread_ret != 0) { printf("Join线程失败:%d\n", pthread_ret); exit(pthread_ret); } printf("pthread-one exit:%ld\n", (long)pthread_exit_val); return 0; } void * thread_cb_one(void *arg) { char *msg = "pthread_one exit"; return ((void *)msg); } void * thread_cb_two(void *arg) { pthread_exit((void *)2); } ``` 一般说来一个线程如果要结束自己,有如下几种常规操作: 一、该线程正常跑完了自己的业务逻辑然后return了(demo中有) 二、被同一个进程下的其他线程给取消了,也就是pthread_cancel() 三、该线程自己执行了pthread_exit()(demo中有) 四、exit()终极大杀器(全村吃饭) 上面四个选项中一和三都在demo中使用了,第二项暂做保留,第四项额外说下,第四项属于终极大杀器,你在一个进程所创建的任意一个线程中去调用exit()都会直接让所有线程瞬间盖上白布,隔壁的进程们就等你用exit(),然后就等白布一盖、坐等上菜,因为在一个进程中调用exit()会直接导致进程直接退出,名副其实的「全村吃饭」函数。 上面demo你运行一下,如果不出意外的话结果如下图,你们感受一下:  上面demo中,thread_cb_one()和thread_cb_two()的标准说法叫做线程start启动例程,前面说过这种函数就是当线程被创建后自动开始执行的函数,其实感觉上更像JS中的on触发回调,这种线程启动例程的原型是这样的: ```php void *start_routine(void *arg); ``` 也就说这个启动例程函数接受一个任意类型指针(参数由pthread_create()第四个参数传入),返回任意类型指针。所以老李在thread_cb_one()中返回了字符串一个字符串类型,在thread_cb_two()中返回了一个数字int,如果你愿意你可以返回struct、struct指针、union、union指针、struct数组... ...,想你所想、圆你所愿、做你想做,心有多大,舞台就有多宽!你的能力超出你的想象,5G的时代已经来临... 老李,我自己本身都求求你了,别TM BB了,正经写会儿文章吧。 无论你是用pthread_exit()还是用return,最终的值终将会被主控制线程中的pthread_join()顺利捕捉到并打印出来,之后回收线程资源。这里老李插播一个比较有意思的事儿,你将上面demo中的第44行改成char msg[] = "pthread_one exit";再试试看,看看会有什么结果。 GCC编译器(作为菜B记得随时随地给gcc加上-Wall参数,这是为了你好)是不是FBI Warning你了?大概类似于这样:   挖槽,真是一款令人心力交瘁的编程语言,我就改了一行代码你就这样了?作为一款这样的编程语言,如果出错了你千万别怀疑他有BUG,一定是你的问题。快仔细考虑下,这样的一款语言为啥从char *msg改成char msg[]就要FBI Warning?不过看起来就是个Warning,PHP里不也有Warning么,在PHP里Warning和Notice级错误都不用看,一定没问题的肯定能用,又不是不能用,我就运行一下看看咋样。  好像是有些许小问题,返回了一个null不过好像还是能用的最起码没崩,那试试Mac OS吧。  Segmentation fault: 11,直接坟头草三米,连化肥都不用施。其实原因非常简单,就赤裸裸躺在FBI Warning里了「warning: address of stack memory associated with local variable 'msg' returned」,啥意思呢? 因为msg是分配在了thread_cb_one()的栈内存上,一旦thread_cb_one()执行完毕后栈内存就要释放,其中的msg直接盖白布了。那为啥用char *msg就没问题呢?因为char *msg分配在进程的静态数据区,所以无论线程是否结束释放栈空间都不会影响到进程的静态数据区。 所以,无论是多进程还是多线程编程,切记逻辑跑完后如果返回的内容是在栈内存上的,统统的不要!那如果要这么做怎么办,使用全局变量是一个办法,或者在逻辑里手动malloc内存返回该内存区域的指针。 到了这里本该可以继续下一步了,可是总有一些小机灵鬼喜欢抖机灵,问一些比较骚气的问题:老李你说要是在main主控制线程里用pthread_exit()会有啥结果?我还不等创建出来的线程运行结束,main主控制线程就率先pthread_exit(),这样会不会也会引发全村吃饭? ```php #include
#include
#include
#include
void * thread_cb_one(void *); void * thread_cb_two(void *); int main(int argc, char *argv[]) { int pthread_ret; pthread_t thread_id_one, thread_id_two; pthread_ret = pthread_create(&thread_id_one, NULL, thread_cb_one, NULL); if (pthread_ret != 0) { printf("创建线程失败:%d\n", pthread_ret); exit(pthread_ret); } pthread_ret = pthread_create(&thread_id_two, NULL, thread_cb_two, NULL); if (pthread_ret != 0) { printf("创建线程失败:%d\n", pthread_ret); exit(pthread_ret); } pthread_exit((void *)0); printf("看我看我看我看我看我\n"); return 0; } void * thread_cb_one(void *arg) { for (int i=1; i <= 5; i++) { printf("线程一打空炮\n"); sleep(1); } return ((void *)0); } void * thread_cb_two(void *arg) { for (int i=1; i <= 5; i++) { printf("线程二打空炮\n"); sleep(1); } pthread_exit((void *)0); } ```  看起来并没有全村吃饭,有两点可以得到印证: 一、主控制线程肯定是直接GG了,因为第22行的文案没有打印出来 二、两个新创建的线程很欢乐地跑完了全部业务逻辑 那在两个新线程欢乐地跑着的时候,主线程算是怎么个状态?其实本来我很久之前就知道通过ps -elfT可以查看到全部都是zombie状态的,结果今天我手贱又多试了几个新的ps选项,结果出现了一个比较魔幻的结果,你们感受一下:  腿叔们,本来我李子不手贱的话我就可以笃定的写一个较为确定的结论了可惜今天手贱多折腾了一下,现在是李子把握不住了啊!这两个结果的共同点是主线程都是zombie状态了,不同点是第二种ps选项中三个线程全部是zombie,所以这里就有两个问题了: 一、按说创建了两个新线程还在欢乐地跑呢,main为啥还显示zombie 二、两个新线程到底是不是zombie 第一个问题我大概知道是怎么回事,据说是个bug,这个bug的提出链接就在这里http://lkml.iu.edu/hypermail/linux/kernel/0902.0/00194.html ,提出者叫做Kaz Kylheku,很快有一个叫做Oleg Nesterov的老哥就回复他了,大概意思就是:首先恭喜你发现了一个我们已知的问题,其次是这玩意我们不打算修了你爱咋咋地。第二个问题,李子真的把握不住,各位懂的佬后台可以留言发我,初步猜测会不会是ps在不同选项的情况下会出现信息不一致。 到了这里你是不是以为线程的退出取消就算完了?其实这才刚开始。腿子,线程取消退出这水很深,你太年轻把握不住,让李叔来教你。叔问你:那既然有了return为啥还要整一个pthread_exit()?看看,把握不住了不是? 为啥?为了烧纸。return只管埋不管烧纸,pthread_exit()不仅管埋还负责烧纸属于一条龙服务,十八相送一水黑。pthread_exit()的作用颇有点儿类似于进程中的atexit()系列函数,线程取消之前顺道做很多释放、清理无用资源的作用,比较典型的就是多线程持有的一些锁资源、或者同步数据,如果一个持有锁资源的线程直接取消了那么这个锁并没有被释放,其他剩余存活的线程只会继续傻傻等待,大眼瞪小眼。 如何才能具体实现「十八相送一水黑的一条龙服务」呢?这里需要引入一对函数(实际上在Linux下是宏,你可以追踪一下头文件中的define定义),原型分别是: 一、void pthread_cleanup_push(void (*routine)(void *), void *arg),routine表示压入到线程清理程序专用栈中的清理程序,arg表示要传递给该清理程序的参数 二、void pthread_cleanup_pop(int execute),该宏从栈中弹出一个清理程序,注意是弹出,弹出来的清理程序具体指不执行,取决于参数以及执行环境。如果execute如果为非零数那么,无论是return还是pthread_exit()都会统统执行;如果execute为零,那么清理函数无论如何都不会执行 你看着这一对函数结尾的push和pop睁着无知而又懵懂的大眼睛无辜地问「老李,怎么又是push又是pop,我怎么感觉闻到了栈的味道呢?」,我不禁欣慰地点了点头说「是的,每个线程都会拥有一个属于自己的清理函数栈,你每执行一次pthread_cleanup_push()就会向该栈中压入一个清理逻辑程序,当然你需要一个配对的pthread_cleanup_pop()从该栈中向外弹出并执行该清理程序」。所以啊,pthread_cleanup_push()与pthread_cleanup_pop()切记一定要成双成对的出现而且要出现在同一个代码块中(了解下C语言的代码块定义)。下面来一把demo: ```php #include
#include
#include
#include
#include
void * thread_cb_one(void *); void * thread_cb_two(void *); void clean(void *); int main(int argc, char *argv[]) { int pthread_ret; void *pthread_return_val; pthread_t thread_id_one, thread_id_two; pthread_ret = pthread_create(&thread_id_one, NULL, thread_cb_one, NULL); if (pthread_ret != 0) { printf("创建线程失败:%d\n", pthread_ret); exit(pthread_ret); } pthread_ret = pthread_create(&thread_id_two, NULL, thread_cb_two, "no"); if (pthread_ret != 0) { printf("创建线程失败:%d\n", pthread_ret); exit(pthread_ret); } pthread_ret = pthread_join(thread_id_one, &pthread_return_val); if (pthread_ret != 0) { printf("join线程失败:%d\n", pthread_ret); exit(pthread_ret); } pthread_ret = pthread_join(thread_id_two, &pthread_return_val); if (pthread_ret != 0) { printf("join线程失败:%d\n", pthread_ret); exit(pthread_ret); } return 0; } void * thread_cb_one(void *arg) { pthread_cleanup_push(clean, "thread_one clean 1"); pthread_cleanup_push(clean, "thread_one clean 2"); for (int i=1; i <= 4; i++) { printf("线程一打空炮\n"); sleep(1); } pthread_cleanup_pop(0); pthread_cleanup_pop(0); return ((void *)0); } void * thread_cb_two(void *arg) { pthread_cleanup_push(clean, "thread_two clean 1"); pthread_cleanup_push(clean, "thread_two clean 2"); for (int i=1; i <= 2; i++) { printf("线程二打空炮\n"); sleep(1); } // 如果收到了clear指令,那就立即清除 if (0 == strcmp((char *)arg, "clear")) { pthread_exit((void *)2); } pthread_cleanup_pop(0); pthread_cleanup_pop(1); pthread_exit((void *)2); } void clean(void *arg) { printf("触发线程回收函数:%s\n", (char *)arg); } ``` 你需要注意的是第19行和第57行、第60行和第61行这四行。解析一下运行结果: 一、thread_cb_one()中,pthread_cleanup_pop(0)时,return不会执行清理;如果将参数从0改为1,那么将会执行 二、如果给thread_cb_two()传入的arg参数是clear,那么程序将会在第58行处执行并退出,并自动执行清理函数;如果thread_cb_two()传入的arg参数是除clear外的其他任意字符串,那么将程序会跳过58行继续向下执行,此时虽然末尾也执行了pthread_exit(),但是只有参数为1的pthread_cleanup_pop()弹出并执行了清理程序,0的pthread_cleanup_pop()仅仅弹出清理程序但并不执行 是不是有点儿积懵而B、积沙成雕?thread_cb_two()中后半段怎么越看越沙雕?其实简单,你要从线程清理这个需求点上去出发而思考这个问题,什么情况下线程需要执行清理程序?那就是当线程遇到了「并不能使线程正常执行完的业务逻辑错误时才需要执行」,而57行就相当于一种逻辑错误检测,遇到这种错误,线程就需要销毁自己持有的锁啊等等操作。而从60行开始一直到最后实际上相当于该线程没有遇到任何意外的错误,一路小跑愉快地走完了整个逻辑,这种情况下是程序是通过正常途径已经释放掉需要释放的资源了,所以其实并不需要执行清理程序,只需要将其从栈中弹出即可。所以,在实际正常程序中,第61行的参数也应该是0而不是1,上述demo中用1是为了demo而demo,如果在此处也传入参数1,那反而就要出问题咯! 所以综上:使用return时候,不会有一条龙清理服务,但是你依然要pop出清理程序;而只有使用pthread_exit()才会有一条龙清理服务。你会问使用return时我给pthread_cleanup_pop()传参1不就得了?小伙子,再回去仔细回味一下上一段吧。  截止到目前为止,还漏有两点未说明,不过我个人认为放到后面再引入更为合适,这两点分别为: 一、pthread_cancel()函数 二、线程的连接(join)与分离(detach)两种状态 如果说不出意外地你仔细实践并思考了的话,线程的创建与销毁以及清理这块儿,应该没有太大问题了。