
个人主页爱和冰阔乐专栏传送门《数据结构与算法》 、C学习方向C方向学习爱好者⭐人生格言得知坦然 失之淡然博主简介文章目录前言一、线程栈共享地址空间不等于共用一块栈1.1 主线程栈 vs 子线程栈1.2 代码验证主线程访问子线程栈二、先用 pthread_create 把线程模型跑出来2.1 创建第一个线程2.2 编译时链接 pthread2.3 用 ps -aL 看 LWP2.4 三个实验结论三、Linux 为什么还需要 pthread 库3.1 Linux 的轻量级进程模型3.2 pthread 库的作用3.3 C11 的 thread四、pthread 线程控制创建、等待、退出、取消与分离4.1 创建线程4.2 线程等待4.3 线程 ID 与 pthread_self4.4 线程返回值与传参传对象4.5 线程终止的三种方式4.6 线程分离总结前言前两篇把“线程是什么、为什么更轻”讲清楚这一篇开始真正使用 pthread。先看线程自己的栈再创建线程、观察 PID/LWP随后把 pthread 库为什么存在以及创建、等待、返回、取消、分离这些接口一次串起来。这里仍然保持“先看现象再解释原因”的写法。一、线程栈共享地址空间不等于共用一块栈1.1 主线程栈 vs 子线程栈虽然 Linux 将线程和进程不加区分地统一到了task_struct但对待其地址空间的 stack 还是有区别的。主线程的栈简单理解就是 main 函数的栈空间。在 fork 的时候实际上就是复制了父亲的 stack 空间地址然后写时拷贝COW以及动态增长。主线程使用的地址空间上的栈会自动扩容。如果扩充超出上限则栈溢出会报段错误。子线程的栈不再是向下生长的而是事先固定下来的。线程栈一般是调用 glibc/uclibc 等的 pthread 库接口pthread_create创建的线程在文件映射区共享区。其中使用 mmap 系统调用memmmap(NULL,size,prot,MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK,-1,0);这种 stack不能动态增长一旦用尽就没了。这是和生成进程的 fork 不同的地方。一般默认 8M。在 glibc 中通过 mmap 得到 stack 之后底层调用sys_clone系统调用intsys_clone(structpt_regs*regs){unsignedlongclone_flags;unsignedlongnewsp;int__user*parent_tidptr,*child_tidptr;clone_flagsregs-bx;// 获取了 mmap 得到的线程的 stack 指针newspregs-cx;parent_tidptr(int__user*)regs-dx;child_tidptr(int__user*)regs-di;if(!newsp)newspregs-sp;returndo_fork(clone_flags,newsp,regs,0,parent_tidptr,child_tidptr);}对于子线程的 stack它是在进程的地址空间中 mmap 出来的一块内存区域原则上是线程私有的。但同一个进程的所有线程生成的时候会浅拷贝生成者的task_struct的很多字段如果愿意其他线程也还是可以访问到的。1.2 代码验证主线程访问子线程栈int*pnullptr;void*run(void*args){inta123;pa;// 子线程把栈上变量地址暴露给全局指针while(true){sleep(1);}}intmain(){pthread_t tid;pthread_create(tid,nullptr,run,nullptr);sleep(1);while(true){std::cout*p:*pstd::endl;// 主线程读取子线程栈上的数据sleep(1);}pthread_join(tid,nullptr);return0;}主线程成功读取到了子线程栈上的变量值 123。虽然栈是线程私有的但同进程内其他线程并非完全不能访问。二、先用 pthread_create 把线程模型跑出来2.1 创建第一个线程要创建线程需要使用第三方库pthread函数为pthread_create。#includeiostream#includestring#includeunistd.h#includepthread.hvoid*threadrun(void*args){std::string name(constchar*)args;while(true){std::cout我是新线程:name namestd::endl;sleep(1);}returnnullptr;}intmain(){pthread_t tid;pthread_create(tid,nullptr,threadrun,(void*)thread-1);while(1){std::cout我是主线程...std::endl;sleep(1);}return0;}新线程执行threadrun主线程继续向下运行 while 函数。threadrun就是新线程的入口函数编译出来是一组虚拟地址表示的代码和数据main函数执行时是另一组虚拟地址表示的代码和数据。主线程和新线程将不同虚拟地址作为入口天然执行各自代码的一部分自然将虚拟地址进行划分。2.2 编译时链接 pthread注意线程函数是第三方库不属于系统调用编译连接时需要加-lpthreadtest_thread:TestThread.cc g -o $ $^ -lpthread2.3 用 ps -aL 看 LWP怎么证明是两个线程用ps查看发现只有一个进程psajx|head-1psajx|greptest_thread|grep-vgrepkill 杀掉进程两个线程都退出了——证明信号是线程共享的。只想看到线程的话# -a 表示所有ps-aL两个线程的 PID 是一样的——它们都属于同一个进程。LWPLight Weight Process表示轻量级进程编号不一样证明进程中存在两个轻量级进程。编号小的是主线程且LWP 和 PID 数值是相等的。CPU 调度的时候看 PID 还是 LWP答案是LWP因为 Linux 中只有轻量级进程。2.4 三个实验结论1. 时间片共享创建新线程时系统层多了个轻量级进程时间片在创建时要等分给不同的线程。假设进程只有 10ms 时间片创建两个线程则各自 5ms。时间片也是资源共享的。若时间片不共享每创建一个线程就再分 10ms恶意程序不断创建线程获取时间片就会导致程序出错。2. 异常崩溃让新线程进行除 0 操作——任何一个线程崩溃都会导致整个进程崩溃。3. 打印混杂多个线程向显示器打印消息会混杂在一起。两个线程访问的是同一个终端文件显示器本质上也是共享资源没有加保护时多个线程同时写入就可能出现原子性问题可以通过加锁解决。三、Linux 为什么还需要 pthread 库3.1 Linux 的轻量级进程模型Linux 系统不存在真正意义上的线程。它所谓的概念是用轻量级进程模拟的。OS 中只有轻量级进程所谓模拟线程是我们的说法。Linux 系统里只存在轻量级进程。所以 Linux 操作系统只会提供创建轻量级进程的系统调用如vfork创建的子进程和父进程共享地址空间intg_val100;intmain(void){pid_t pidvfork();if(pid0){perror(vfork);return1;}elseif(pid0){// childprintf([子进程] pid%d, 父pid%d, 修改前 g_val%d\n,getpid(),getppid(),g_val);g_val888;// 修改全局变量父子共享内存printf([子进程] 修改后 g_val%d\n,g_val);_exit(0);// vfork 子进程必须 _exit不能 return}else{// fatherprintf([父进程] pid%d, 子pid%d, g_val%d\n,getpid(),pid,g_val);}return0;}另一个系统调用clone也是创建共享地址空间的轻量级进程总结Linux 操作系统只存在轻量级进程创建子进程的模式就两种——1. 把整个进程拷贝 2. 创建轻量级进程地址空间不变拷贝 PCB。操作系统不会提供真正创建线程的接口。3.2 pthread 库的作用作为用户只认线程。在 Linux 中使用线程用户和 OS 出现了鸿沟——两者不匹配。Linux 设计者为了解决这个问题给用户提供了一层软件层pthread 库。其工作是将创建轻量级进程封装起来给用户提供一批创建线程的接口。Linux 的线程实现在用户层我们称之为用户级线程。pthread 库被称为原生线程库——出生在 Linux 系统上的线程库与 Linux 系统强绑定但属于用户层。3.3 C11 的 threadC11 引入了多线程std::thread。在老版 GCC 中不写-pthread会报错因为在 Linux 下thread本质是封装 pthread 库的。新版系统高版本 glibc libstdc不写-pthread也能跑但这是隐式间接依赖不是正确写法。根本原因libstdc.so本身写了NEeded依赖libpthread.so动态链接器加载libstdc.so时自动顺带加载libpthread.so。#includethreadvoidhello(){while(true){std::cout新线程:hello world,pid:getpid()std::endl;sleep(1);}}intmain(){std::threadt(hello);while(true){std::cout我是主线程...,pid:getpid()std::endl;sleep(1);}t.join();return0;}在 Windows 下 C11 是通过封装 Windows 创建线程的接口。语言的跨平台和可移植性一般怎么实现——所有平台全部实现一份通过条件编译形成库。四、pthread 线程控制创建、等待、退出、取消与分离4.1 创建线程intpthread_create(pthread_t*thread,constpthread_attr_t*attr,void*(*start_routine)(void*),void*arg);参数说明thread返回线程 ID输出型参数attr设置线程属性NULL 表示使用默认属性start_routine函数地址函数指针线程启动后要执行的函数入口arg传给线程启动函数的参数返回值成功返回 0失败返回错误码。错误检查pthreads 函数出错时不会设置全局变量 errno而是将错误代码通过返回值返回。建议通过返回值判定因为读取返回值比读取线程内的 errno 变量开销更小。4.2 线程等待线程创建好后新线程要被主线程等待否则会出现类似僵尸进程的问题——内存泄漏。intpthread_join(pthread_t thread,void**retval);thread创建线程时对应的 ID 值retval线程退出时的返回值void*routine(void*args){std::string namestatic_castconstchar*(args);intcnt5;while(cnt--){std::cout我是一个新线程:我的名字是: namestd::endl;sleep(1);}returnnullptr;}intmain(){pthread_t tid;intnpthread_create(tid,nullptr,routine,(void*)thread-1);(void)n;pthread_join(tid,nullptr);// 不关心退出结果return0;}4.3 线程 ID 与 pthread_self线程的 ID 和 LWP 一样吗打印出来发现不是底层的 LWPvoidshowtid(pthread_ttid){printf(tid:%ld\n,tid);// 数字很大}以十六进制打印printf(tid:0x%lx\n,tid);怎么证明打印的 tid 就是新线程的 ID用pthread_self——返回调用线程自己的 IDstd::stringFormatId(pthread_ttid){charid[64];snprintf(id,sizeof(id),0x%lx,tid);returnid;}void*routine(void*args){std::string namestatic_castconstchar*(args);pthread_t tidpthread_self();intcnt5;while(cnt--){std::cout新线程: name 我的ID是:FormatId(tid)std::endl;sleep(1);}returnnullptr;}intmain(){pthread_t tid;pthread_create(tid,nullptr,routine,(void*)thread-1);showtid(tid);intcnt5;while(cnt--){std::coutmain线程 ID是:FormatId(pthread_self())std::endl;sleep(1);}pthread_join(tid,nullptr);return0;}主线程 join 时等待的就是新线程。main函数也是一个线程也可以获得自己的 tid。上述代码中主线程和新线程同时调用FormatId函数——有两个执行流同时进入该函数内部这就是函数被重入。该函数是可重入函数因为函数内部只有局部变量。4.4 线程返回值与传参传对象线程的返回值是void*最后会被pthread_join拿到。主线程需要用void**来获取返回值pthread_join(tid,ret);std::coutret is:(longlongint)retstd::endl;为什么pthread_join没有处理异常信号因为等待的目标线程如果异常了整个进程都退出了包括 main 线程。只有线程正常 return / pthread_exit()pthread_join才会把void*返回值填到ret里面。异常信号是进程要处理的话题。传参和返回值可以是任意类型包括自定义对象classTask{public:Task(inta,intb):_a(a),_b(b){}intExecute(){return_a_b;}private:int_a,_b;};classResult{public:Result(intresult):_result(result){}intGetResult(){return_result;}private:int_result;};void*routine(void*args){Task*tstatic_castTask*(args);sleep(1);Result*resnewResult(t-Execute());sleep(1);returnres;}intmain(){pthread_t tid;Task*tnewTask(10,20);// 堆上开辟防止主线程提前退出pthread_create(tid,nullptr,routine,t);Result*retnullptr;pthread_join(tid,(void**)ret);std::cout新线程结束运行结果: ret-GetResult()std::endl;deletet;return0;}4.5 线程终止的三种方式方式 1return线程的入口函数进行 return 就是线程终止。注意不能调用exit——exit 是终止进程的会导致所有线程退出方式 2pthread_exit谁调用终止谁返回值也是void*void*routine(void*args){Task*tstatic_castTask*(args);Result*resnewResult(t-Execute());pthread_exit(res);std::cout你看到我了吗std::endl;// 不会执行}方式 3pthread_cancel取消线程常规做法是主线程取消新线程pthread_create(tid,nullptr,routine,t);sleep(3);// 保证新线程已启动pthread_cancel(tid);std::cout新线程被取消了std::endl;void*retnullptr;pthread_join(tid,ret);std::cout新线程结束运行结果: (longlong)retstd::endl;线程被取消时退出结果是-1PTHREAD_CANCELED。pthread_join拿到的返回值就是线程退出时设定的返回值。4.6 线程分离如果主线程不想等待新线程可以让新线程结束时自己释放——设置为分离状态detach。可以是主线程分离新线程也可以是新线程自己分离// 主线程分离新线程pthread_detach(tid);// 新线程自己分离pthread_detach(pthread_self());intmain(){pthread_t tid;pthread_create(tid,nullptr,routine,(void*)thread-1);pthread_detach(tid);std::cout新线程被分离了std::endl;intnpthread_join(tid,nullptr);if(n!0)std::coutpthread_join error: strerror(n)std::endl;return0;}分离的线程依旧在进程的地址空间中进程的资源依旧可以访问。只是主线程不等待它了。如果线程被设置为分离状态不需要 joinjoin 会失败。总结到这里线程已经从概念走到了真正可操作的接口pthread_create ↓ 线程开始执行 ↓ return / pthread_exit / pthread_cancel ↓ pthread_join 回收 或 pthread_detach 分离同时要记住pthread 是用户层接口真正被 Linux 内核调度的仍然是 LWP。下一篇就继续追pthread_t、NPTL、线程栈和clone的底层关系。资源分享【Linux】线程到底是什么从轻量级进程、虚拟地址到页表与 MMU一次理清线程底层模型【Linux】信号到底什么时候被处理sigaction、中断、用户态内核态与 SIGCHLD【Linux】malloc 1GB 内存为什么没立刻占满从缺页、COW 到 Cache/TLB看懂线程为什么更轻