当前位置: 首页 > news >正文

Linux多线程-进阶

目录

Linux进程VS线程

进程和线程

进程的多个线程共享

进程和线程的关系

Linux线程控制 

POSIX线程库

创建线程

设置私有全局变量

获取线程ID 

对操作系统来说

 线程ID及进程地址空间布局

线程终止

线程等待

图形总结


前篇文章,简单的介绍了多线程。下面这篇文章,将会对线程进行给为详细的介绍。话不多说,开始吧。

Linux进程VS线程

进程和线程

在Linux中,进程是承担分配资源的基本实体,线程是调度的基本单位。

线程共享进程数据,但也拥有自己的一部分数据:

  • 线程ID。
  • 一组寄存器。(存储每个线程的上下文信息)
  • 栈。(每个线程都有临时的数据,需要压栈出栈)
  • errno。(C语言提供的全局变量,每个线程都有自己的)
  • 信号屏蔽字。
  • 调度优先级。

进程的多个线程共享

因为进程的多个线程共享在同一个地址空间,因此所谓的代码段(Text Segment)、数据段(Data Segment)都是共享的:

  • 如果定义一个函数,在各线程中都可以调用。
  • 如果定义一个全局变量,在各线程中都可以访问到。

除此之外,各线程还共享以下进程资源和环境:

  • 文件描述符表。(进程打开一个文件后,其他线程也能够看到)
  • 每种信号的处理方式。(SIG_IGN、SIG_DFL或者自定义的信号处理函数)
  • 当前工作目录。(cwd)
  • 用户ID和组ID。

进程和线程的关系

进程和线程的关系如下图:

在此之前我们接触到的都是具有一个线程执行流的进程,即单线程进程。

Linux线程控制 

POSIX线程库

pthread线程库是应用层的原生线程库:

  • 应用层指的是这个线程库并不是系统接口直接提供的,而是由第三方帮我们提供的。
  • 原生指的是大部分Linux系统都会默认带上该线程库。
  • 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以"pthread_"打头的。
  • 要使用这些函数库,要通过引入头文件<pthread.h>。
  • 连接这些线程函数库时要使用编译器命令的 "-lpthread" 的选项。

创建线程

调用的函数为:pthread_create

pthread_create函数的函数原型如下:

功能:创建一个新的线程
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

参数说明:

  • thread:获取创建成功的线程ID,该参数是一个输出型参数。
  • attr:用于设置创建线程的属性,传入NULL表示使用默认属性。
  • start_routine:该参数是一个函数地址,表示线程启动后要执行的函数,其为回调函数。
  • arg:传给第三个函数指针指向的函数的参数,可以设置为NULL,同样也可以传类对象。

返回值说明:

  • 线程创建成功返回0,失败返回错误码。

错误检查:

  • 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
  • pthreads函数出错时不会设置全局变量errno(而大部分POSIX函数会这样做),而是将错误代码通过返回值返回。
  • pthreads同样也提供了线程内的errno变量,以支持其他使用errno的代码。对于pthreads函数的错误,建议通过返回值来判定,因为读取返回值要比读取线程内的errno变量的开销更小。

比如我们下面让主线程创建一个新线程 

当一个程序启动时,就有一个进程被操作系统创建,与此同时一个线程也立刻运行,这个线程就叫做该进程的主线程。 

  • 主线程是产生其他子线程的线程
  • 一般来说主线程最后必须要完成某些特殊的操作,比如说关闭/回收各个子线程。

下面我们按照要求,让主线程调用pthread_create函数创建一个新线程,创建的新线程去执行自己的新线程代码,而主线程则继续执行其本身剩余的代码。

#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>using namespace std;void* rout(void *arg)
{int i;for( ; ; ){cout << "I am thread 1" << endl;sleep(1);}
}int main()
{pthread_t tid;int ret;if(pthread_create(&tid, NULL, rout, NULL) != 0){fprintf(stderr, "pthread_create : %s\n",strerror(ret));exit(1);}int i;for( ; ; ){cout << "I am main pthread" << endl;sleep(1);}return 0;
}

运行代码后可以看到,新线程每隔一秒执行一次打印操作,而主线程每隔两秒执行一次打印操作。

当我们运行下面的脚本时,我们就可以看到,虽然当前是有两个线程在运行的,但是实际上是看到仅有一个进程的,因此这两个线程是同属于一个进程的。

ps axj | head -1 ; ps axj | grep test | grep -v grep

除此之外使用ps -aL命令,可以显示当前的轻量级进程。 

  • 默认情况下,不带-L,看到的就是一个个的进程。
  • -L就可以查看到每个进程内的多个轻量级进程。
ps -aL | head -1 ; ps -aL | grep test | grep -v gre

其中两个线程的PID是一样的,所以二者属于同一个进程。除PID之外,还有一个我们陌生的LWP

其中,LWP(Light Weight Process)就是轻量级进程的标识符,相当于进程中的PID。

对于操作系统来说PID是用来管理进程所用,那么显而易见,LWP是操作系统管理线程所用。

其中LWP 等于 PID 的就为该进程的主线程。其中图中第一个也就为主线程。

特别注意: 所以在Linux中,基本的调度单位为线程,并且应用层的线程与内核的LWP是一一对应的,所以实际上操作系统调度的时候采用的是LWP,而并非PID,只不过我们之前接触到的都是单线程进程,其PID和LWP是相等的,所以对于单线程进程来说,调度时采用PID和LWP是一样的。

同样,我们可以对前代码进行简单修饰,分别打印自己的PID,PPID,LWP从而验证(获取LWP是调用gettid()函数):

#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>using namespace std;void* rout(void *arg)
{int i;for( ; ; ){printf("I am %thread 1, pid: %d, ppid: %d, LWP: %d\n", getpid(), getppid(), gettid());sleep(1);}
}int main()
{pthread_t tid;int ret;if(pthread_create(&tid, NULL, rout, NULL) != 0){fprintf(stderr, "pthread_create : %s\n",strerror(ret));exit(1);}int i;for( ; ; ){printf("I am main thread...pid: %d, ppid: %d, LWP: %d\n", getpid(), getppid(), gettid());sleep(1);}return 0;
}

可以看到主线程和新线程的PID和PPID是一样的,但是LWP是不同的,其中main thread中的LWP 等于PID,也就是说主线程和新线程虽然是两个执行流,但它们仍然属于同一个进程,其中main线程是主线程。

让主线程创建一批新线程 

下面我们让主线程一次性创建五个新线程,并让创建的每一个新线程都去执行rout函数,也就是说rout函数会被重复进入,即该函数是会被重入的。 

因为多线程是共享地址空间,其实这也暗示着线程可以通过这一点可以进行通信。

#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>using namespace std;void* rout(void *arg)
{char* msg = (char*)arg;int i;for( ; ; ){printf("I am %s, pid: %d, ppid: %d, LWP: %d\n", msg, getpid(), getppid(), gettid());sleep(1);}
}int main()
{pthread_t tid[5];int ret;for(int i = 0; i < 5; i++){char* buffer = (char*)malloc(64);sprintf(buffer, "thread %d", i);if(pthread_create(&tid[i], NULL, rout, (void *)buffer) != 0){fprintf(stderr, "pthread_create : %s\n",strerror(ret));exit(1);}}int i;for( ; ; ){printf("I am main thread...pid: %d, ppid: %d, LWP: %d\n", getpid(), getppid(), gettid());sleep(1);}return 0;
}

 运行成功后,结果如下:

可以看到主线程与五个子线程的PID,PPID全部相同,此时我们可以通过ps -aL查看。

ps -aL | head -1 ; ps -aL | grep test | grep -v gre

那么我们现在猜想,如果我们kill -9 任意一个子线程,那对于整体的进程是有什么影响呢?

可以看到kill -9 LWP任意一个子线程,对于整个进程都会被干掉。

补充:除此之外上,在用户层上,线程的概念是有线程库提供的,所以线程库就要维护线程的概念,那么线程库注定要维护多个线程属性,由此方法便是:先描述,在组织。

设置私有全局变量

在多线程中,全局变量是被所有的线程同时看到并访问的,是属于共享内存。那么如果说线程想要一个自己的私有全局变量呢?

这就需要用到例如如下的表示:

这样就会使得每一个线程都有一个全局变量g_val,但是每个线程互不干扰。

__thread  int g_val = 100;

其中_thread是编译选项,只可以修饰内部类型。

我们也可以验证,对每一个线程乘自己的编号:

#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>using namespace std;__thread  int g_val = 100;void* rout(void *arg)
{char* msg = (char*)arg;int i = *(msg + 7) - '0';g_val *= i;for( ; ; ){printf("I am %s, pid: %d, ppid: %d, LWP: %d, g_val = %d\n", msg, getpid(), getppid(), gettid(), g_val);sleep(1);}
}int main()
{pthread_t tid[5];int ret;for(int i = 0; i < 5; i++){char* buffer = (char*)malloc(64);sprintf(buffer, "thread %d", i);if(pthread_create(&tid[i], NULL, rout, (void *)buffer) != 0){fprintf(stderr, "pthread_create : %s\n",strerror(ret));exit(1);}}int i;for( ; ; ){printf("I am main thread...pid: %d, ppid: %d, LWP: %d\n", getpid(), getppid(), gettid());sleep(1);}return 0;
}

可以看到确实对于不同的线程,其g_val确实不同的值。 

获取线程ID 

  • pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
  • 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
  • pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
  • 线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID。
  • 与gettid()不同的是,gettid是Linux 特有系统调用,pthread_self是POSIX 线程(pthread)库接口,跨平台(Linux/Unix)。

pthread_self的原型:

pthread_t pthread_self(void);

调用pthread_self函数即可获得当前线程的ID,类似于调用getpid函数获取当前进程的ID。

例如我们对以前的代码,进行修改,将gettid的调用改为pthread_self的调用。 

#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>using namespace std;void* rout(void *arg)
{char* msg = (char*)arg;for( ; ; ){printf("I am %s, pid: %d, ppid: %d, ID: %lu\n", msg, getpid(), getppid(), pthread_self());sleep(1);}
}int main()
{pthread_t tid[5];int ret;for(int i = 0; i < 5; i++){char* buffer = (char*)malloc(64);sprintf(buffer, "thread %d", i);if(pthread_create(&tid[i], NULL, rout, (void *)buffer) != 0){fprintf(stderr, "pthread_create : %s\n",strerror(ret));exit(1);}}int i;for( ; ; ){printf("I am main thread...pid: %d, ppid: %d, ID: %lu\n", getpid(), getppid(), pthread_self());sleep(1);}return 0;
}

其打印效果如下:

注意: 用pthread_self函数获得的线程ID与内核的LWP的值是不相等的,pthread_self函数获得的是用户级原生线程库的线程ID,而LWP是内核的轻量级进程ID,它们之间是一对一的关系。

对操作系统来说
  • 在Linux中,目前的线程实现是Native POSIX Thread Libaray,简称NPTL。在这种实现下,线程又被称为轻量级进程(Light Weighted Process),每一个用户态的线程,在内核中都对应一个调度实体,也拥有自己的进程描述符(task_struct结构体)。
  • 没有线程之前,一个进程对应内核里的一个进程描述符,对应一个进程ID。但是引入线程概念之后,情况发生了变化,一个用户进程下管辖N个用户态线程,每个线程作为一个独立的调度实体在内核态都有自己的进程描述符,进程和内核的描述符一下子就变成了1:N关系,POSIX标准又要求进程内的所有线程调用getpid函数时返回相同的进程ID,如何解决上述问题呢?

Linux内核引入了线程组的概念

struct task_struct {...pid_t pid;pid_t tgid;...struct task_struct *group_leader;...struct list_head thread_group;...
};
  • 多线程的进程,又被称为线程组,线程组内的每一个线程在内核之中都存在一个进程描述符(task_struct)与之对应。进程描述符结构体中的pid,表面上看对应的是进程ID,其实不然,它对应的是线程ID;进程描述符中的tgid,含义是Thread Group ID,该值对应的是用户层面的进程ID
  • 而线程ID等于进程的ID,我们也就称其该线程为进程的主线程,此时其结构体内的group_leader指向自身
/* 线程组ID等于线程ID,group_leader指向自身 */
p->tgid = p->pid;
p->group_leader = p;
INIT_LIST_HEAD(&p->thread_group);
  • 至于线程组其他线程的ID则有内核负责分配,其线程组ID总是和主线程的线程组ID一致,无论是主线程直接创建线程,还是创建出来的线程再次创建线程,都是这样。
if ( clone_flags & CLONE_THREAD )p->tgid = current->tgid;
if ( clone_flags & CLONE_THREAD ) {P->group_lead = current->group_leader;list_add_tail_rcu(&p->thread_group, &p->group_leader->thread_group);
}
  • 强调一点,线程和进程不一样,进程有父进程的概念,但在线程组里面,所有的线程都是对等关系。

 线程ID及进程地址空间布局

我们前面介绍了pthread_self函数,其返回值类型为pthread_t类型,那么pthread_t类型到底是什么类型呢?其取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。

我们还回到这个图,对于其中的共享区,有一个区域叫为mmao区域,其除了存着动态库外,还存储各种除主线程外的各种线程的信息。

那么我们就得到到了此:

 从上图中我们可以看到,每个线程都有自己私有的栈,其中主线程采用的栈是进程地址空间中原生的栈,而其余线程采用的栈就是在共享区中开辟的。除此之外,每个线程都有自己的struct pthread,当中包含了对应线程的各种属性;每个线程还有自己的线程局部存储,当中包含了对应线程被切换时的上下文数据。
每一个新线程在共享区都有这样一块区域对其进行描述,因此我们要找到一个用户级线程只需要找到该线程内存块的起始地址,然后就可以获取到该线程的各种信息。

其中每个线程的库级别的TCP的起始地址叫做线程的ID,也就是图中的pthread_t tid1,pthread_t tid2。其也就是pthread_self函数的返回值。

线程终止

如果需要只终止某个线程而不是终止整个进程,可以有三种方法:

  1. 从线程函数return。
  2. 线程可以自己调用pthread_exit函数终止自己。
  3. 一个线程可以调用pthread_cancel函数终止同一进程中的另一个线程。

对于第一个方法还是显而易见的,那么下面就介绍2和3。

pthread_exit函数原型:

void pthread_exit(void *value_ptr);

参数说明:

  • value_ptr:线程退出时的退出码信息。
  • value_ptr不要指向一个局部变量。

说明一下:

  • 该函数无返回值,跟进程一样,线程结束的时候无法返回它的调用者(自身)。
  • pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其他线程得到这个返回指针时,线程函数已经退出了。

例如,在下面代码中,我们使用pthread_exit函数终止线程,并将线程的退出码设置为6666。

#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>
#include <sys/types.h>using namespace std;void* rout(void *arg)
{char* msg = (char*)arg;for(int i = 0; i < 5; i++){printf("I am %s, pid: %d, ppid: %d, ID: %lu\n", msg, getpid(), getppid(), pthread_self());sleep(1);}pthread_exit((void*)6666);
}int main()
{pthread_t tid[5];int ret;for(int i = 0; i < 5; i++){char* buffer = (char*)malloc(64);sprintf(buffer, "thread %d", i);if(pthread_create(&tid[i], NULL, rout, (void *)buffer) != 0){fprintf(stderr, "pthread_create : %s\n",strerror(ret));exit(1);}}printf("I am main thread...pid: %d, ppid: %d, ID: %lu\n", getpid(), getppid(), pthread_self());for (int i = 0; i < 5; i++){;void* ret = NULL;pthread_join(tid[i], &ret);printf("thread %d[%lu]...quit, exitcode: %d\n", i, tid[i], (long)ret);}return 0;
}

运行效果如下: 

线程是可以被取消的,我们可以使用pthread_cancel函数取消某一个线程,pthread_cancel函数的函数原型如下:

int pthread_cancel(pthread_t thread);

参数说明:

  • thread:被取消线程的ID。

返回值说明:

  • 线程取消成功返回0,失败返回错误码。

线程是可以取消自己的,取消成功的线程的退出码一般是-1。例如在下面的代码中,我们让线程执行一次打印操作后将自己取消。

#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>
#include <sys/types.h>using namespace std;void* rout(void *arg)
{char* msg = (char*)arg;for(int i = 0; i < 5; i++){printf("I am %s, pid: %d, ppid: %d, ID: %lu\n", msg, getpid(), getppid(), pthread_self());sleep(1);pthread_cancel(pthread_self());}
}int main()
{pthread_t tid[5];int ret;for(int i = 0; i < 5; i++){char* buffer = (char*)malloc(64);sprintf(buffer, "thread %d", i);if(pthread_create(&tid[i], NULL, rout, (void *)buffer) != 0){fprintf(stderr, "pthread_create : %s\n",strerror(ret));exit(1);}}printf("I am main thread...pid: %d, ppid: %d, ID: %lu\n", getpid(), getppid(), pthread_self());for (int i = 0; i < 5; i++){;void* ret = NULL;pthread_join(tid[i], &ret);printf("thread %d[%lu]...quit, exitcode: %d\n", i, tid[i], (long)ret);}return 0;
}

运行代码,可以看到每个线程执行一次打印操作后就退出了,退出码为-1。因为我们是在线程执行pthread_exit函数前将线程取消的。

但这里有一个小细节,对于每一个线程,按道理应该只答应一次操作,但实际上是打印了两次,而且每一个都是如此,那么这一定不是巧合。

其原因如下:

  1. pthread_cancel 的异步性

    • 调用 pthread_cancel(pthread_self()) 会 请求取消当前线程,但取消操作是异步的,线程可能不会立即终止。

    • 在取消请求被处理前,线程可能已经执行了下一次循环的 printf,导致重复打印。

  2. 线程取消的默认行为

    • 默认情况下,线程取消的生效时机是 延迟取消(Deferred Cancellation),即线程会在下一个 取消点(如 sleepprintf 等系统调用)时才响应取消请求。

    • 在我们的代码中,printf 和 sleep 都是取消点,因此线程可能在执行完一次循环后,在下一次循环的 printf 或 sleep 时才被实际取消。

线程等待

在我们上面的线程终止的代码中,不难发现还调用了pthread_join函数,其就为线程等待。

为什么要线程等待?

  • 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
  • 创建新的线程不会复用刚才退出线程的地址空间。

其pthread_join函数原型如下:

int pthread_join(pthread_t thread, void **value_ptr);

参数说明:

  • thread:被等待线程的ID。
  • retval:线程退出时的退出码信息。

返回值说明:

  • 线程等待成功返回0,失败返回错误码。

调用该函数的线程将挂起等待,直到ID为thread的线程终止,thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的。

总结如下:

  • 如果thread线程通过return返回,retval所指向的单元里存放的是thread线程函数的返回值。
  • 如果thread线程被别的线程调用pthread_cancel异常终止掉,retval所指向的单元里存放的是常数PTHREAD_CANCELED。
  • 如果thread线程是自己调用pthread_exit终止的,retval所指向的单元存放的是传给pthread_exit的参数。
  • 如果对thread线程的终止状态不感兴趣,可以传NULL给retval参数。

图形总结

学到这里,我们就大致了解了一个线程的生命周期。下面给出一张图,总结本片文章。

相关文章:

  • 湖北理元理律师事务所视角:企业债务优化的三维平衡之道
  • 在uniCloud云对象中定义dbJQL的便捷方法
  • 免杀对抗--PE文件结构
  • 大实验:基于赛灵思csg324100T,pmodMAXsonar的危险距离警报
  • NumPy数组访问
  • MySQL从入门到DBA深度学习指南
  • 算法-数论
  • 每日八股文6.8
  • 通过Cline使用智能体
  • WebFuture 升级提示“不能同时包含聚集KEY和大字段””的处理办法
  • DDR供电设计中的VTT与VREF作用和区别
  • 深究二分查找算法:从普通到进阶
  • 【AIGC】RAGAS评估原理及实践
  • 可可·香奈儿 活出自己
  • 使用Mathematica实现Newton-Raphson收敛速度算法(简单高阶多项式)
  • Beckhoff(倍福) -- MES(ITAC) TCP 通讯
  • Wise Disk Cleaner:免费高效的系统清理专家
  • C++课设:从零开始打造影院订票系统
  • Java中的抽象类
  • 2.1.3_2 编码和调制(下)
  • 不错的网站建设公司/上海最大的seo公司
  • 怎么在拼多多开无货源网店/seo快速排名软件方案
  • 北京网站建设石榴汇/seo关键词怎么填
  • 北大荒建设集团网站/网络软文营销是什么意思
  • wordpress中文文章排版插件/深圳网站优化培训
  • 有建设银行信用卡怎么登陆不了网站/广西百度seo