万字详解Linux系列多线程(上)

Posted 山舟

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了万字详解Linux系列多线程(上)相关的知识,希望对你有一定的参考价值。

文章目录


前言

由于多线程部分内容过多,所以分为两篇来写,下篇传送门:【万字详解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)互斥量(锁)

创建、销毁一个互斥量(锁)需要用到以下的函数:在这里<p>以上是关于万字详解Linux系列多线程(上)的主要内容,如果未能解决你的问题,请参考以下文章</p> 
<p > <a style=万字详解Linux系列多线程(下)

万字详解Linux系列多线程(下)

Linux从青铜到王者第十三篇:Linux多线程四万字详解

java 多线程怎么深入?

万字详解Linux系列文件系统动静态库

万字详解Linux系列文件系统动静态库