万字详解linux系列多线程(上)(代码片段)

山舟 山舟     2023-03-22     270

关键词:

文章目录


前言

由于多线程部分内容过多,所以分为两篇来写,下篇传送门:【万字详解Linux系列】多线程(下)


一、线程

1.概念

在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。


一切进程至少都有一个执行线程。线程在进程内部运行,本质是在进程地址空间内运行,也就是说,进程和线程共享进程地址空间。

运行如下代码,之前讲到用fork创建子进程时,由于写时拷贝,子进程对数据的修改不会影响父进程,但这里vfork使子进程和父进程共享进程地址空间,所以它们看到的是同一片内容,互相修改也是可见的。

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int global = 10;

int main()

	pid_t id = vfork();
	if (id == 0)
	
		//child
		global = 20;//子进程修改全局变量
		return 1;
	
	//father
	printf("global : %d\\n", global);//父进程可见
	return 0;

结构如下,显然子进程的修改对于父进程是可见的。


Linux系统下在CPU眼中,看到的PCB都要比传统的进程更加轻量化。

透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

基于轻量级进程的系统调用,Linux在用户层模拟实现了一套线程的接口,包含在pthread下。


2.优点

  • 创建一个新线程的代价要比创建一个新进程小得多。
  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作少很多。
  • 线程占用的资源要比进程少很多。
  • 能充分利用多处理器的可并行数量。
  • 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
  • 计算密集型(执行流的大部分任务以计算为主,如加密、解密、排序、查找等)应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
  • I/O密集型应用(执行流的大部分任务以IO为主,如访问数据库、访问网络等)为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

3.缺点

  • 性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失。这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
  • 健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
  • 缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
  • 编程难度提高:编写与调试一个多线程程序比单线程程序困难得多

4.线程异常

  • 单个线程如果出现除零、野指针等问题导致线程崩溃,进程也会随着崩溃。
  • 线程是进程的执行分支,线程出异常就类似于进程出异常,进而触发信号机制终止进程。而一旦进程终止,所有相关资源都被回收,该进程内的所有线程也就随即退出。

二、进程与线程

请认真区分线程与进程之间的区别与联系,后面很多地方都要注意这两者之间的关系。

1.进程和线程

进程是资源分配的基本单位,而线程是调度的基本单位。

线程虽然共享进程数据,但也拥有自己的一部分数据:线程ID、一组寄存器、栈、errno、信号屏蔽字、调度优先级。

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


2.进程的多个线程共享

进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用;如果定义一个全局变量,在各线程中都可以访问到。

除此之外,各线程还共享以下进程资源和环境:文件描述符表、每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)、当前工作目录、用户id和组id。


三、线程控制

1.线程创建

创建线程用到的函数是pthread_create。

参数

  • thread:输出型参数,返回线程ID。
  • attr:设置线程的属性,attr为NULL表示使用默认属性。
  • start_routine:本质是函数指针,即线程启动后要执行的函数 。
  • arg:传给线程启动函数start_routine的参数。

返回值:成功返回0;失败返回错误码。


下面的代码通过函数创建线程,并让创建的线程每秒打印一次"thread!"及其pid、ppid,而主函数中每两秒打印一次"main thread"及其pid、ppid。

#include <stdio.h>
#include <pthread.h>
#include <sys/types.h>
#include <unistd.h>

void* routine(void* arg)

	char* msg = (char*)arg;
	//创建的线程每秒打印一次thread!及其pid、ppid
	while (1)
	
		printf("%s pid : %d ppid : %d\\n", msg, getpid(), getppid());
		sleep(1);
	


int main()

	pthread_t tid;
	pthread_create(&tid, NULL, routine, (void*)"thread!");

	while (1)
	
		//主线程(main)每两秒打印一次main thread及其pid、ppid
		printf("main thread  pid : %d ppid : %d\\n", getpid(), getppid());
		sleep(2);
	

	return 0;

上面的程序在编译时就有个小细节,如下:

运行结果如下,这两个线程的pid和ppid都相同,所以说它们是同一个进程的两个执行流


2.线程查看

命令行查看

可以通过ps的-L选项查看轻量级进程(这里可以看到不同的线程):


所以操作系统再调度的时候是以LWP为单位的而并非PID,因为这里显然两个线程的PID相同,如果通过PID就无法区分。


用函数查看

查看线程的编号用的函数是pthread_self,直接调用pthread_self(),它的返回值即是该线程的对应编号。

注意该返回值并不等于上面的LWP,因为该返回值是用户层的数据,而LWP是内核层的数据。


代码如下:

#include <stdio.h>
#include <pthread.h>
#include <sys/types.h>
#include <unistd.h>

void* routine(void* arg)//让该线程什么都不做

	return NULL;//return代表线程结束


int main()

	pthread_t tid[5];//创建5个线程
	int i = 0;
	//循环5次创建线程
	for (; i < 5; i++)
	
		//创建线程
		pthread_create(&tid[i], NULL, routine, (void*)"thread!");
		printf("%d tid is %lx pid : %d ppid : %d\\n", i, tid[i], getpid(), getppid());//按照long的十六进制打印
	

	printf("main thread tid : %lx pid : %d ppid : %d\\n", pthread_self(), getpid(), getppid());//按照long的十六进制打印

	return 0;


3.线程等待

这里用到的函数是pthread_join。

参数thread

下面通过代码来演示函数的调用方法:

#include <stdio.h>
#include <pthread.h>
#include <sys/types.h>
#include <unistd.h>

void* routine(void* arg)

	return NULL;


int main()

	pthread_t tid[5];
	int i = 0;
	for (; i < 5; i++)
	
		pthread_create(&tid[i], NULL, routine, (void*)"thread!");
		printf("%d tid is %lx pid : %d ppid : %d\\n", i, tid[i], getpid(), getppid());
	

	printf("main thread tid : %lx pid : %d ppid : %d\\n", pthread_self(), getpid(), getppid());

	for (i = 0; i < 5; i++)
	
		//第一个参数是线程的标识,也就是tid[i]的值
		pthread_join(tid[i], NULL);//第二个参数先设置为NULL
		printf("thread %d[%lx] quit!\\n", i, tid[i]);
	

	return 0;

结果如下:


参数retval

该参数可以理解为被等待线程返回时的“退出码”(但要注意参数的类型),即告诉主线程执行得如何。

void* routine(void* arg)

	//返回10(这里没有什么逻辑需求,可以随意设置)
	return (void*)10;//void*强转


int main()

	pthread_t tid[5];
	int i = 0;
	for (; i < 5; i++)
	
		pthread_create(&tid[i], NULL, routine, (void*)"thread!");
		printf("%d tid is %lx pid : %d ppid : %d\\n", i, tid[i], getpid(), getppid());
	

	printf("main thread tid : %lx pid : %d ppid : %d\\n", pthread_self(), getpid(), getppid());

	for (i = 0; i < 5; i++)
	
		void* ret = NULL;
		pthread_join(tid[i], &ret);
		printf("thread %d[%lx] quit! code : %d\\n", i, tid[i], (int)ret);//直接将获得的返回值ret强转为int打印
	

	return 0;

结果如下:

retval可以理解为被等待线程返回时的“退出码”(但要注意参数的类型),即告诉主线程执行得如何。比如有具体逻辑时,可以让线程完成处理或返回1,如果失败返回0,这样主线程就可以知道每一个线程处理的结果如何。

【万字详解Linux系列】进程控制 时提到过waitpid可以拿到被等待进程的退出码和收到的信号,那么这里的pthread_join为什么不设置一个参数来获取被等待线程收到的信号呢?
原因是做不到,从前文可以看到,一个进程内的所有线程的PID都是相同的,而在【万字详解Linux系列】进程信号 中可以看到发送的信号都是针对进程(PID)的,也就是说一旦某一个线程出现某些问题收到信号,整个进程(包括其中的所有线程)就都挂掉了,主线程根本没机会获取收到的信号。


4.进程退出

这里暂时先仅讨论线程正常退出

return

由上可以看到,线程执行完routine的代码后会通过return结束,或是主线程(main)通过return返回,这时所有的线程都退出。


pthread_exit

#include <stdio.h>
#include <pthread.h>
#include <sys/types.h>
#include <unistd.h>

void* routine(void* arg)

	pthread_exit((void*)19);//线程退出,"退出码"设置为19


int main()

	pthread_t tid[5];
	int i = 0;
	for (; i < 5; i++)
	
		pthread_create(&tid[i], NULL, routine, (void*)"thread!");
		printf("%d tid is %lx pid : %d ppid : %d\\n", i, tid[i], getpid(), getppid());
	

	printf("main thread tid : %lx pid : %d ppid : %d\\n", pthread_self(), getpid(), getppid());

	for (i = 0; i < 5; i++)
	
		void* ret = NULL;
		pthread_join(tid[i], &ret);
		printf("thread %d[%lx] quit! code : %d\\n", i, tid[i], (int)ret);
	

	return 0;

注意这里要注意与exit的区别,exit是退出进程,也就是说如果在routine函数中用exit退出,一旦有一个线程运行到此,整个进程(包括所有线程)就都结束了,而上面的pthread_exit仅仅是某一个线程退出而已。


pthread_cancel

这个函数一般用于一个线程取消(终止)其它线程。

(当然,也可以自己取消自己,只不过如果仅仅是为了这个功能,前面两个方法已经足够了)

下面通过代码在主线程中取消数组下标为0和3的线程:

#include <stdio.h>
#include <pthread.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>

void* routine(void* arg)

	printf("tid : %p\\n", pthread_self());


int main()

	pthread_t tid[5];
	int i = 0;
	for (; i < 5; i++)
	
		pthread_create(&tid[i], NULL, routine, (void*)"thread!");
		printf("%d tid is %p pid : %d ppid : %d\\n", i, tid[i], getpid(), getppid());
	

	pthread_cancel(tid[0]);
	pthread_cancel(tid[3]);

	printf("main thread tid : %p pid : %d ppid : %d\\n", pthread_self(), getpid(), getppid());

	for (i = 0; i < 5; i++)
	
		void* ret = NULL;
		pthread_join(tid[i], &ret);
		printf("thread %d[%p] quit! code : %d\\n", i, tid[i], (int)ret);
	

	return 0;

结果如下:


四、pthread_t

上面几乎每个函数都有与pthread_t相关的参数或返回值,那么这个pthread_t的含义到底是什么呢?

事实上pthread_t的含义取决于不同的实现方式。对于Linux使用的NPTL实现而言,pthread_t类型的线程ID,本质就是进程地址空间上的一个地址。

从上面几段程序的运行结果(我在代码中特意将其转化为十六进制或地址进行打印)也可以看到,pthread_t本质就是一个地址


五、线程互斥

1.相关概念

  • 临界资源:多线程执行流共享的资源就叫做临界资源。
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区访问临界源,通常对临界资源起保护作用。
  • 原子性(之后讨论如何实现):不会被任何调度机制打断的操作,保证只有两态,要么完成,要么未开始。

这些概念大多数在【万字详解Linux系列】进程间通信(IPC)时提到过了,这里仅详细解释一下原子性。在进程间通信或者线程间互相访问时,通常都会涉及到临界资源的问题,为了防止出现该问题采取了许多措施来保证原子性。原子性即是在一个进程(或线程)看来,一块临界资源要么未被另一个进程(或线程)操作,要么已经被另一个进程(或线程)操作完毕,而不会有其它的情况。


2.互斥量

(1)引出

下面简单模拟一个“抢票”的程序,逻辑比较简单,主要是为了引出线程互斥。

#include <stdio.h>
#include <pthread.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>

#define NUM 1000
int tickets = NUM;//定义共1000张票

void* GetTicket(void* arg)

	int index = (int)arg;//第index个线程

	while (1)//一直抢票直到没有剩下票
	
		if (tickets > 0)//剩余票数大于0就抢
		
			usleep(100);//等待100微秒
			printf("thread[%d]正在抢票...剩余%d张票\\n", index, tickets--);//tickets先打印再--
		
		else//tickets<=0
		
			break;
		
	
	printf("thread[%d] quit\\n", index);//线程退出


int main()

	pthread_t thd[5];//创建5个线程
	int i = 0;

	for (; i < 5; i++)
	
		//创建线程
		pthread_create(&thd[i], NULL, GetTicket, (void*)i);
	

	//等待每个线程
	for (i = 0; i < 5; i++)
	
		pthread_join(thd[i], NULL);
	

	return 0;

运行结果如下,显然出现了问题,最后票剩下了-3张,这显然是有问题的,究其原因是因为没有保证进程互斥。


原因主要有如下三点:

  1. if语句判断条件为真以后,代码可能会并发的切换到其他线程。
  2. usleep
    注意到我在代码的抢票逻辑中加了一个usleep(100)来让线程停滞100微秒,虽然100微秒在我们看来非常短,但在操作系统看来就不是了,所以在这漫长的100微秒内,可能会有很多其他线程进入该代码段影响当前线程。
  3. tickets–
    首先tickets是个全局变量,在线程看来它这个变量本身就是个临界资源,如果不加保护很容易出问题。前面说到了原子操作可以保证互斥,但是这里的tickets–并不是一个原子操作,它对应三个步骤:(1)将全局变量ticket从内存加载到寄存器中 (2)更新寄存器里面的值,执行自减一 (3)将新值从寄存器写回全局变量ticket的内存地址。其中如果哪个步骤被打断,都会影响到tickets的值,进而出现上面的运行结果。

要解决以上问题,需要做到三点:

  1. 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  2. 如果多个线程同时执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  3. 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

而上面这些操作本质就是需要一把锁,Linux中提供的锁叫做互斥量。


(2)互斥量(锁)

创建、销毁一个互斥量(锁)需要用到以下的函数:在这里<h3>linux从青铜到王者第十三篇:linux多线程四万字详解(代码片段)</h3>
<p>系列文章目录文章目录系列文章目录前言一、Linux线程概念1.什么是线程2.线程的优点3.线程的缺点4.线程的异常5.线程的用途二、进程和线程的对比1.进程和线程2.多进程的应用场景有哪些?三、线程控制1.POSIX线程库2.创建线程...  <a style=查看详情

万字详解linux系列文件系统动静态库(代码片段)

文章目录一、文件系统1.inode2.磁盘3.目录4.软硬链接(1)软链接(2)硬链接5.文件的三个时间信息二、动静态库1.基本原理2.认识库3.动静态加载的优缺点静态加载动态加载感谢阅读,如有错误请批评指正一、文... 查看详情

万字详解linux系列进程控制(代码片段)

文章目录一、环境变量1.基本概念2.常见的环境变量(1)PATH(2)HOME(3)SHELL(4)HISTSIZE(5)SSH_TTY3.与环境变量相关的指令4.在代码中获取环境变量(1)argc和argv(2&#x 查看详情

万字详解linux系列进程信号(代码片段)

文章目录一、信号简介1.查看信号2.信号的本质3.信号的记录和发送4.从键盘输入的信号5.signal自定义信号6.处理信号的一般方式二、信号产生1.通过终端按键(键盘)产生信号CoreDump(核心转储)2.程序异常事后调试3.调用函数&... 查看详情

万字详解linux系列基础io(代码片段)

文章目录前言(1)当前目录(2)stdin、stdout、stderr一、open(1)标志位(2)O_WRONLY(3)O_CREAT二、close,read,write三、文件描述符1.概念2.原理3.分配规则四、重定向1.输出 查看详情

万字详解linux系列基础io(代码片段)

文章目录前言(1)当前目录(2)stdin、stdout、stderr一、open(1)标志位(2)O_WRONLY(3)O_CREAT二、close,read,write三、文件描述符1.概念2.原理3.分配规则四、重定向1.输出 查看详情

万字详解linux系列进程间通信(ipc)(代码片段)

文章目录一、进程间通信1.目的2.如何通信3.分类二、管道1.概念2.匿名管道(1)实现父子进程间通信(2)fork角度的理解(3)文件描述符角度的理解(4)匿名管道特点(5)四种情况(6&#x... 查看详情

万字详解java线程安全,面试必备!(代码片段)

来源:blog.csdn.net/u014454538/article/details/985158071.Java中的线程安全Java线程安全:狭义地认为是多线程之间共享数据的访问。Java语言中各种操作共享的数据有5种类型:不可变、绝对线程安全、相对线程安全、线程兼容、... 查看详情

linux进程信号万字详解(上)(代码片段)

🎇Linux:博客主页:一起去看日落吗分享博主的在Linux中学习到的知识和遇到的问题博主的能力有限,出现错误希望大家不吝赐教分享给大家一句我很喜欢的话:看似不起波澜的日复一日,一定会在某一天... 查看详情

万字狂淦多线程__(多线程学习笔记)(代码片段)

文章目录多线程学习总结多线程概述多线程并发的理解分析程序当中存在几个线程实现线程的第一种方式strat和run的区别实现多线程的第二种方式采用匿名内部类的方式线程的生命周期获取当前线程sleep方法线程调度多线程的安... 查看详情

linux入门多线程(线程概念生产者消费者模型消息队列线程池)万字解说(代码片段)

目录1️⃣线程概念什么是线程线程的优点线程的缺点线程异常线程异常Linux进程VS线程2️⃣线程控制创建线程获取线程的id线程终止等待线程线程分离3️⃣线程互斥进程线程间的互斥概念互斥量互斥量的接口互斥量的实现原理研... 查看详情

[linux]linux多线程详解(代码片段)

目录1.线程概念1.1什么是线程1.2从操作系统看线程1.3线程的分类1.4线程的优缺点2.线程控制2.1线程创建2.2线程终止2.3线程等待2.4线程分离3.线程安全3.1线程不安全的现象3.1如何解决--互斥锁3.1.1互斥锁原理3.1.2互斥锁接口3.2死锁3.2.1... 查看详情

linux进阶|万字详解docker镜像的制作,手把手学会!(代码片段)

  创作不易,来了的客官点点关注,收藏,订阅一键三连❤😜  前言运维之基础——Linux。我是一个即将毕业的大学生,超超。如果你也在学习Linux,不妨跟着萌新超超一起学习Linux,拿下Linux,一... 查看详情

万字长文详解yolov1-v5系列模型(代码片段)

一,YOLOv1Abstract1.Introduction2.UnifiedDetectron2.1.NetworkDesign2.2Training2.4.Inferences4.1ComparisontoOtherReal-TimeSystems5,代码实现思考二,YOLOv2摘要YOLOv2的改进1,中心坐标位置预测的改进2,1个gird只能对应一个目标的改进3,backbone的改进4, 查看详情

[linux]linux多线程详解(代码片段)

目录1.线程概念1.1什么是线程1.2从操作系统看线程1.3线程的分类1.4线程的优缺点2.线程控制2.1线程创建2.2线程终止2.3线程等待2.4线程分离3.线程安全3.1线程不安全的现象3.1如何解决--互斥锁3.1.1互斥锁原理3.1.2互斥锁接口3.2死锁3.2.1... 查看详情

java多线程基础--线程生命周期与线程协作详解(代码片段)

前言各位亲爱的读者朋友,我正在创作Java多线程系列文章,本篇我们将梳理基础内容:线程生命周期与线程协作这是非常基础的内容,本篇仅从知识完整性角度出发,做一次梳理。作者按:本篇按照自己... 查看详情

java多线程基础--线程生命周期与线程协作详解(代码片段)

前言各位亲爱的读者朋友,我正在创作Java多线程系列文章,本篇我们将梳理基础内容:线程生命周期与线程协作这是非常基础的内容,本篇仅从知识完整性角度出发,做一次梳理。作者按:本篇按照自己... 查看详情

linux进程详解——万字总结,复习利器(代码片段)

这里写目录标题一、图解计算机体系结构(1)底层硬件(2)驱动程序(3)操作系统(4)SystemCall(系统调用)(5)用户操作接口(6)用户层二、进程1、什么是进程2、创建... 查看详情