【万字详解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.互斥量(锁)

创建、销毁一个互斥量(锁)需要用到以下的函数:在这里插入图片描述

加锁、解锁需要用到如下的函数:

在这里插入图片描述


了解了与锁相关的函数后,就可以针对上面有问题的代码进行修改:

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

#define NUM 1000
int tickets = NUM;//定义共1000张票
pthread_mutex_t lock;//创建一个锁

void* GetTicket(void* arg)
{
    
    
	int index = (int)arg;//第index个线程

	while (1)//一直抢票直到没有剩下票
	{
    
    
		pthread_mutex_lock(&lock);//每次开始抢票就加锁
		if (tickets > 0)//剩余票数大于0就抢
		{
    
    
			usleep(100);//等待100微秒
			printf("thread[%d]正在抢票...剩余%d张票\n", index, tickets--);//tickets先打印再--
			pthread_mutex_unlock(&lock);//抢完票后解锁
		}
		else//tickets<=0
		{
    
    
			pthread_mutex_unlock(&lock);//没抢到,解锁
			break;
		}
	}
	printf("thread[%d] quit\n", index);//线程退出
}

int main()
{
    
    
	pthread_mutex_init(&lock, NULL);//初始化锁
	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);
	}
	
	pthread_mutex_destroy(&lock);//销毁锁
	return 0;
}

结果如下,用锁进行互斥后就不会再出现不同线程同时访问临界资源的问题了。
在这里插入图片描述
关于锁还有一下几点需要注意的地方:

  1. 加锁可以保护临界资源,但是也要注意到,在大部分情况下加锁本身都会让性能下降,而且很难避免,所以要尽可能减少加锁带来的性能开销。
  2. 线程被加锁后也有可能中途被切换走(注意运行中途被切换走一般是没有解锁的),但是在它被切换走期间,其他线程不能访问临界资源。因为在其他线程看来,锁并没有被解锁,所以其它线程不能访问。
  3. 锁要被每个线程申请,所以锁本身也就是一个临界资源,而锁存在又是为了保护临界资源,所以谁来保护锁呢?这里是由于关于锁的操作时原子的,所以锁本身一定是安全的,这样它所保护的临界资源也就是安全的了。

3.死锁

死锁是指在一组进程中的各个进程均占有一部分不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于的一种永久等待状态。

(1)四个必要条件

  1. 互斥条件:一个资源每次只能被一个执行流使用。
  2. 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:一个执行流已获得的资源,在未使用完之前不能强行剥夺。
  4. 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。

(2)如何避免死锁

  1. 破坏上述形成死锁的四个必要条件。
  2. 加锁顺序一致。
  3. 避免锁未释放的情况。
  4. 将资源一次性进行分配。

六、重入与线程安全

1.重入

(1)概念

同一个函数被不同的执行流调用,当前一个执行流还没有执行完,就有其他的执行流再次进入,称之为重入。

一个函数在重入的情况下运行结果不会出现任何不同或者任何问题,则该函数是可重入函数,否则就是不可重入函数。


(2)常见不可重入的情况

  • 调用了malloc等动态申请的函数,因为malloc等动态申请的函数是用全局链表来管理堆的。
  • 调用了标准IO库函数,标准IO库中很多函数的实现都以不可重入的方式使用全局数据结构。
  • 在可重入函数体内使用静态的数据结构之后该函数就不可重入了。

(3)常见可重入的情况

  • 不使用全局变量或静态变量。
  • 不使用通过malloc或者new开辟出的空间。
  • 不调用不可重入函数。。
  • 不返回静态或全局数据,即所有数据都由函数的调用方提供。
  • 使用本地数据,或通过使用全局数据的拷贝来保护全局数据。

2.线程安全

(1)概念

线程安全:多个线程并发执行同一段代码时,不会出现不同的结果。

线程不安全常见于对全局变量或者静态变量进行操作时没有锁保护的情况下。


(2)常见的线程不安全的情况

  • 不保护使用共享变量(一般为全局变量或静态变量)的函数。
  • 函数状态随着被调用,其状态发生了变化。
  • 返回指向静态变量指针的函数。
  • 内部调用了线程不安全函数的函数。

(3)常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限。
  • 类或者函数对于线程来说都是原子操作。
  • 多个线程之间的切换不会导致该函数的执行结果存在二义性。

3.区别与联系

如果函数是可重入的,那么它就是线程安全的函数;如果函数是不可重入的,那就不能被多个线程同时使用,有可能会引发线程安全问题。

可重入函数是线程安全函数的一种,线程安全函数不一定是可重入的函数,而可重入函数一定是线程安全的。

如果在函数内部将对临界资源的访问加上锁,则这个函数是线程安全的。


感谢阅读,如有错误请批评指正

猜你喜欢

转载自blog.csdn.net/weixin_51983604/article/details/123588756
今日推荐