UNIX环境高级编程-多进程、多线程编程

本文详细介绍了C语言在Linux下的内存布局,包括栈、堆和数据段。接着讲解了多进程编程,特别是fork()系统调用的工作原理,以及进程间的继承关系。此外,还通过示例代码展示了如何使用fork()创建进程。最后,文章讨论了多线程编程,包括线程的状态、创建线程的函数pthread_create(),以及如何使用互斥锁避免线程间的资源竞争问题,并提到了死锁的概念和防止策略。
摘要由CSDN通过智能技术生成


1 - C语言内存布局图

在深入理解Linux下多进程编程之前,我们首先要了解Linux下进程在运行时的内存布局。 Linux 进程内存管理的对象都是虚拟内存,每个进程先天就有 0-4G 的各自互不干涉的虚拟内存空间,0—3G 是用户空间执行用户自己的代码, 高 1GB 的空间是内核空间执行 Linu x 系统调用,这里存放在整个内核的代码和所有的内核模块,用户所看到和接触的都是该虚拟地址,并不是实际的物理内存地址。 Linux下一个进程在内存里有三部分的数据,就是”代码段”、”堆栈段”和"数据段”。如下图:

在这里插入图片描述

  • 栈:
    栈内存由编译器在程序编译阶段完成,进程的栈空间位于进程用户空间的顶部并且是向下增长,每个函数的每次调用都会在栈空间中开辟自己的栈空间,函数参数、局部变量、函数返回地址等都会按照先入者为栈顶的顺序压入函数栈中,函数返回后该函数的栈空间消失,所以函数中返回局部变量的地址都是非法的。
  • 堆:
    堆内存是在程序执行过程中分配的,用于存放进程运行中被动态分配的的变量,大小并不固定,堆位于非初始化数据段和栈之间,并且使用过程中是向栈空间靠近的。当进程调用 malloc 等函数分配内存时,新分配的内存并不是该函数的栈帧中,而是被动态添加到堆上,此时堆就向高地址扩张;当利用 free 等函数释放内存时,被释放的内存从堆中被踢出,堆就会缩减。因为动态分配的内存并不在函数栈帧中,所以即使函数返回这段内存也是不会消失。

2 - 多进程编程

(1)了解进程

Linux下有两个基本的系统调用可以用于创建子进程:fork()和vfork()。

我们着重介绍fork()。fork在英文中是"分叉"的意思。一个进程在运行中,如果使用了fork,就产生了另一个进程,于是进程就”分叉”了,所以这个名字取得很形象。在我们编程的过程中,一个函数调用只有一次返回(return),但由于fork()系统调用会创建一个新的进程,这时它会有两次返回。一次返回是给父进程,其返回值是子进程的PID(Process ID),第二次返回是给子进程,其返回值为0。所以我们在调用fork()后,需要通过其返回值来判断当前的代码是在父进程还是子进程运行,如果返回值是0说明现在是子进程在运行,如果返回值>0说明是父进程在运行,而如果返回值<0的话,说明fork()系统调用出错。fork 函数调用失败的原因主要有两个:

  1. 系统中已经有太多的进程;
  2. 该实际用户 ID 的进程总数超过了系统限制。

每个子进程只有一个父进程,并且每个进程都可以通过getpid()获取自己的进程PID,也可以通过getppid()获取父进程的PID,这样在fork()时返回0给子进程是可取的。一个进程可以创建多个子进程,这样对于父进程而言,他并没有一个API函数可以获取其子进程的进程ID,所以父进程在通过fork()创建子进程的时候,必须通过返回值的形式告诉父进程其创建的子进程PID。这也是fork()系统调用两次返回值设计的原因。

(2)进程测试代码

接下来我们写一段程序来了解一下fork()函数创建进程的用法:

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

#define    buf   "Hello, nice to meet you!\n"
int a = 1;
int main(int argc, char argv[])
{
	pid_t    pid;
	int      b = 11;
	if (write(STDOUT_FILENO, buf, sizeof(buf)) < 0)
	{
		printf("Write string to STDOUT_FILENO errer:%s\n", strerror(errno));
		return -1;
	}
	printf("Before fork\n");

	pid = fork();
	if (pid < 0)
	{
	        printf("Fork() create child process failure:%s\n", strerror(errno));
		return -1;
	}

	else if (0 == pid)
	{
		printf("Child process PID[%d] start running. My parent PID is [%d]\n", getpid(), getppid());
		a++;
		b++;
	}

	else
	{
		printf("Parent process PID[%d] continue running, and my son PID is [%d]\n", getpid(), pid);
	}
	/*父进程和子进程都会运行到return 0,所以下面的语句会打印两次哦*/
	printf("PID = %d, a = %d,  b = %d\n", getpid(), a, b);
	return 0;
}

直接运行结果:
在这里插入图片描述

fork()系统调用会创建一个新的子进程,这个子进程是父进程的一个副本。这也意味着,系统在创建新的子进程成功后,会将父进程的文本段、数据段、堆栈都复制一份给子进程,但子进程有自己独立的空间,子进程对这些内存的修改并不会影响父进程空间的相应内存。就像上面的程序一样,我们在子进程中改变了a和b的值,但是父进程中并没有改变。这时系统中出现两个基本完全相同的进程(父、子进程),这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略。

但是我们输出重定向到一个文件中的结果:
在这里插入图片描述

结果就不一样了,Befere fork打印了两次,这是为什么呢?大家可以去了解一下行缓冲和全缓冲的概念,这篇文章:行缓冲、全缓冲、无缓冲以及用户缓冲区、内核缓冲区介绍

给大家解释一下:printf()库函数在标准输出是终端时默认是行缓冲,所以遇见‘\n’就会输出;而当标准输出重定向到文件中后该函数是全缓冲的;所以第一次的结果就是遇见‘\n’输出Before fork。但是我们重定向到文件中就是全缓冲了,全缓冲到缓冲区的大小4k才会输出,显然没有到达,所以就留在缓冲区了,但是进程结束就会刷新缓冲区将缓冲区的内容输出,所以子进程结束输出一次,父进程结束输出一次。

那为什么’Hello, nice to meet you!‘只打印了一次呢?因为write()是无缓冲的,直接输出。

可以理解为第一次缓冲区中没有数据,已经输出了,所以只有一次;第二次父子进程的缓冲区中都有‘Before fork’,所以输出两次。

(3)子进程继承父进程

从上面的例子中我们可以知道,知道子进程从父进程那里继承什么或未继承什么将有助于我们今后的编程。
子进程自父进程继承到:

  • 堆栈
  • 内存
  • 打开文件的描述符(对应的文件的位置由父子进程共享)
  • 信号(signal)控制设定
  • 当前工作目录
  • 控制终端
  • nice值 (译者注:nice值由nice函数设定,该值表示进程的优先级, 数值越小,优先级越高)
  • 进程调度类别(scheduler class) (译者注:进程调度类别指进程在系统中被调度时所属的类别,不同类别有不同优先级,根据进程调度类别和nice值,进程调度程序可计算出每个进程的全局优先级(Global process prority),优先级高的进程优先执行)

子进程自己独有:

  • 进程号
  • 不同的父进程号(即子进程的父进程号与父进程的父进程号不同, 父进程号可由getppid函数得到)
  • 自己的文件描述符和目录流的拷贝(译者注: 目录流由opendir函数创建,因其为顺序读取,顾称“目录流”)
  • 父进程设置的锁(因为如果是排他锁,被继承的话就矛盾了)

我们在服务器进行多进程编程的时候,我们需要注意一点,因为子进程可以继承父进程的文件描述符,所以我们才能这样完成多进程服务器编程的,这是最基本的,不然没法实现的。


3 - 多线程编程

在操作系统原理的术语中,线程是进程的一条执行路径。线程在Unix系统下,通常被称为轻量级的进程,线程虽然不是进程,但却可以看作是Unix进程的表亲,所有的线程都是在同一进程空间运行,这也意味着多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage)。 一个进程可以有很多线程,每条线程并行执行不同的任务。

(1)了解线程

一个进程创建后,会首先生成一个缺省的线程,通常称这个线程为主线程(或称控制线程),C程序中,主线程就是通过main函数进入的线程,由主线程调用 pthread_create() 创建的线程称为子线程,子线程也可以有自己的入口函数,该函数由用户在创建的时候指定。每个线程都有自己的线程ID,可以通过 pthread_self() 函数获取。最常见的线程模型中,除主线程较为特殊之外,其他线程一旦被创建,相互之间就是对等关系,不存在隐含的层次关系。

主线程和子线程的默认关系是:无论子线程执行完毕与否,一旦主线程执行完毕退出,所有子线程执行都会终止。

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void*arg);

说明: pthreand_create()用来创建一个线程,并执行第三个参数start_routine所指向的函数。

  • thread:是一个pthread_t类型的指针,他用来返回该线程的线程ID。每个线程都能够通过pthread_self()来获取自己的线程ID(pthread_t类型);
  • start_routine:是一个函数指针,它指向的函数原型是 void *func(void *),这是所创建的子线程要执行的任务(函数);
  • arg:就是传给了所调用的函数的参数,如果有多个参数需要传递给子线程则需要封装到一个结构体里传进去;
  • 第二个参数是线程的属性,其类型是pthread_attr_t类型,其定义如下:
typedef struct
{
 	 int detachstate; 线程的分离状态
	 int schedpolicy; 线程调度策略
	 struct sched_param schedparam; 线程的调度参数
	 int inheritsched; 线程的继承性
 	 int scope; 线程的作用域
	 size_t guardsize; 线程栈末尾的警戒缓冲区大小
	 int stackaddr_set;
	 void * stackaddr; 线程栈的位置
	 size_t stacksize; 线程栈的大小
}pthread_attr_t;

(2)线程状态介绍

线程函数执行完毕退出,或以其他非常方式终止,线程进入终止态,但是为线程分配的系统资源不一定释放,可能在系统重启之前,一直都不能释放,终止态的线程,仍旧作为一个线程实体存在于操作系统中,什么时候销毁,取决于线程属性。在这种情况下,主线程和子线程通常定义以下两种关系:

  • 可会合(joinable):
    这种关系下,主线程需要明确执行等待操作,在子线程结束后,主线程的等待操作执行完毕,子线程和主线程会合,这时主线程继续执行等待操作之后的下一步操作。主线程必须会合可会合的子线程。在主线程的线程函数内部调用子线程对象的wait函数实现,即使子线程能够在主线程之前执行完毕,进入终止态,也必须执行会合操作,否则,系统永远不会主动销毁线程,分配给该线程的系统资源也永远不会释放。
  • 相分离(detached):
    表示子线程无需和主线程会合,也就是相分离的,这种情况下,子线程一旦进入终止状态,这种方式常用在线程数较多的情况下,有时让主线程逐个等待子线程结束,或者让主线程安排每个子线程结束的等待顺序,是很困难或不可能的,所以在并发子线程较多的情况下,这种方式也会经常使用。

线程的分离状态决定一个线程以什么样的方式来终止自己,在默认的情况下,线程是非分离状态的,这种情况下,原有的线程等待创建的线程结束,只有当pthread_join函数返回时,创建的线程才算终止,释放自己占用的系统资源,而分离线程没有被其他的线程所等待,自己运行结束了,线程也就终止了,马上释放系统资源。

创建线程时一般会将线程设置为分离状态,具体有两种方法:

  • 线程里面调用 pthread_detach(pthread_self()) 这个方法最简单
  • 在创建线程的属性设置里设置PTHREAD_CREATE_DETACHED属性

下面代码中有涉及到分离和非分离两种状态,注释中都有注解。

(3)线程测试代码

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <pthread.h>
#include <unistd.h>

void *thread_worker1(void *args);
void *thread_worker2(void *args);

int main(int argc, char *argv[])
{
	int             shared_var = 1000;
	pthread_t       tid;          //线程ID
	pthread_attr_t  thread_attr;  //创建线程属性变量

	/*调用pthread_attr_init 函数初始化线程属性*/
	if(pthread_attr_init(&thread_attr))
	{
		printf("pthread_attr_init() failure: %s\n",strerror(errno));
		return -1;
	}
	
	/*设置线程的栈大小为120K*/
	if(pthread_attr_setstacksize(&thread_attr, 120*1024))
	{
		printf("pthread_attr_setstacksize() failure:%s\n", strerror(errno));
		return -1;
	}

	/*将线程的属性设置为分离状态,分离线程没有被其他的线程所等待,自己运行结束了,线程也就终止了,马上释放系统资源。*/
	if(pthread_attr_setdetachstate(&thread_attr, PTHREAD_CREATE_DETACHED))
	{
		printf("pthread_attr_setdetachstate() failure:%s\n", strerror(errno));
		return -1;
	}

	/*创建的work1是分离状态,结束后马上释放系统资源*/
	pthread_create(&tid, &thread_attr, thread_worker1, &shared_var);
	printf("Thread worker1 tid[%ld] created ok\n", tid);

	/*创建的work2是非分离状态,所有的线程等待创建的线程结束后,只有当pthread_join函数返回时,创建的线程才算终止,释放自己占用的系统资源*/
	pthread_create(&tid, NULL, thread_worker2, &shared_var);
	printf("Thread worker2 tid[%ld] created ok\n", tid);

	/*摧毁释放线程属性*/
	pthread_attr_destroy(&thread_attr);

	/*work2是非分离状态,需要调用pthread_join() 等待第二个子线程(work2)退出,当然主线程也就阻塞在这里不会往下继续执行了。*/
	pthread_join(tid, NULL);
	
	while(1)
	{
		printf("Main/Control thread shared_var:%d\n", shared_var);
		sleep(10);
	}

	return 0;
}

void *thread_worker1(void *args)
{
	int *ptr = (int *)args;
	if(!args)
	{
		printf("%s() get invalid arguments\n", __FUNCTION__);
		pthread_exit(NULL);
	}

	printf("Thread worker1 [%ld] start running...\n", pthread_self());

	while(1)
	{
		printf("===worker[1]===:%s before shared_var: %d\n", __FUNCTION__, *ptr);
		*ptr += 1;
		sleep(2);
		printf("===worker[1]===:%s after sleep shared_var: %d\n", __FUNCTION__, *ptr);	       
	}
	printf("Thread worker1 exit...\n");
	return NULL;
}

void *thread_worker2(void *args)
{
	int *ptr = (int *)args;
	if(!args)
	{
		printf("%s() get invalid arguments\n", __FUNCTION__);
		pthread_exit(NULL);
	}

	printf("Thread worker2 [%ld] start running...\n", pthread_self());

	while(1)
	{
		printf("===worker[2]===:%s before shared_var: %d\n", __FUNCTION__, *ptr);
		*ptr += 1;
		sleep(2);
		printf("===worker[2]===:%s after sleep shared_var: %d\n", __FUNCTION__, *ptr);	       
	}
	printf("Thread worker2 exit...\n");
	return NULL;
}

在创建两个线程时,我们都通过第四个参数将主线程栈中的 shared_var 变量地址传给了子线程,因为所有线程都是在同一进程空间中运行,而只是子线程有自己独立的栈空间,所以这时所有子线程都可以访问主线程空间的shared_var变量。

运行结果:
在这里插入图片描述

主线程创建子线程后究竟是子线程还是主线程先执行,或究竟哪个子线程先运行系统并没有规定,这个依赖操作系统的进程调度策略。当然因为主线程调用了pthread_join会导致主线程阻塞,所以主线程不会往下继续执行while(1)循环。

从上面的运行结果我们可以看到,thread_worker2 在创建后首先开始运行,在开始自加之前值为初始值1000,然后让该值自加后休眠2秒后再打印该值发现不是1001而是1002了。这是由于shared_var 这个变量会被两个子线程同时访问修改导致。如果一个资源会被不同的线程访问修改,那么我们把这个资源叫做临界资源,那么对于该资源访问修改相关的代码就叫做临界区

(4)互斥锁

那么我想让上面的代码中的变量让work1和work2分别改动,不能在一方sleep的时候去改动,带怎么办呢?那我们就上锁:work1执行的时候上锁,work2来的时候看见work1上锁了,就在外面等待(阻塞),或者等会儿再来看看(非阻塞)。

我们来尝试一下用锁的机制来解决共享资源的问题,来修改一下代码:

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <pthread.h>
#include <unistd.h>

void *thread_worker1(void *args);
void *thread_worker2(void *args);

typedef struct worker_ctx_s
{
	int               shared_var;
	pthread_mutex_t   lock;
}work_ctx_t;//因为要要传两个参数,所以只能用结构体封装起来

int main(int argc, char *argv[])
{
	work_ctx_t    worker_ctx;
	pthread_t       tid;//线程ID
	pthread_attr_t  thread_attr;

	worker_ctx.shared_var = 1000;
	pthread_mutex_init(&worker_ctx.lock, NULL);//互斥锁初始化,一般设置为null

	if(pthread_attr_init(&thread_attr))
	{
		printf("pthread_attr_init() failure: %s\n",strerror(errno));
		return -1;
	}

	if(pthread_attr_setstacksize(&thread_attr, 120*1024))
	{
		printf("pthread_attr_setstacksize() failure:%s\n", strerror(errno));
		return -1;
	}

	if(pthread_attr_setdetachstate(&thread_attr, PTHREAD_CREATE_DETACHED))
	{
		printf("pthread_attr_setdetachstate() failure:%s\n", strerror(errno));
		return -1;
	}

	pthread_create(&tid, &thread_attr, thread_worker1, &worker_ctx);
	printf("Thread worker1 tid[%ld] created ok\n", tid);

	pthread_create(&tid, NULL, thread_worker2, &worker_ctx);
	printf("Thread worker2 tid[%ld] created ok\n", tid);

	pthread_attr_destroy(&thread_attr);
	pthread_join(tid, NULL);

	while(1)
	{
		printf("Main/Control thread shared_var:%d\n", worker_ctx.shared_var);
		sleep(10);
	}

	pthread_mutex_destroy(&worker_ctx.lock);
}

void *thread_worker1(void *args)
{
	work_ctx_t   *ctx = (work_ctx_t *)args;
	if(!args)
	{
		printf("%s() get invalid arguments\n", __FUNCTION__);
		pthread_exit(NULL);
	}

	printf("Thread worker1 [%ld] start running...\n", pthread_self());

	while(1)
	{
		/*申请锁,非阻塞锁,如果被占用返回非0,没有占用返回0*/
		if(0 != pthread_mutex_trylock(&ctx->lock))
		{
			continue;
		};//非阻塞锁,没上锁就就上锁,上锁了就返回,该干嘛干嘛
		printf("===worker[1]===:%s before shared_var: %d\n", __FUNCTION__, ctx->shared_var);
		ctx->shared_var += 1;
		sleep(2);
		printf("===worker[1]===:%s after sleep shared_var: %d\n", __FUNCTION__, ctx->shared_var);

		pthread_mutex_unlock(&ctx->lock);//解锁
		sleep(1);
	}
	printf("Thread worker1 exit...\n");
	return NULL;
}
void *thread_worker2(void *args)
{
	work_ctx_t *ctx = (work_ctx_t *)args;
	if(!args)
	{
		printf("%s() get invalid arguments\n", __FUNCTION__);
		pthread_exit(NULL);
	}

	printf("Thread worker2 [%ld] start running...\n", pthread_self());

	while(1)
	{
		pthread_mutex_lock(&ctx->lock);//阻塞锁,第二个线程上锁,一直等到第二个线程解锁然后执行
		printf("===worker[2]===:%s before shared_var: %d\n", __FUNCTION__, ctx->shared_var);
		ctx->shared_var += 1;
		sleep(2);
		printf("===worker[2]===:%s after sleep shared_var: %d\n", __FUNCTION__, ctx->shared_var);

		pthread_mutex_unlock(&ctx->lock);//用完之后解锁
		sleep(1);
	}
	printf("Thread worker2 exit...\n");
	return NULL;
}

pthread_mutex_lock() 来申请锁,这里是阻塞锁,如果锁被别的线程持有则该函数不会返回;
pthread_mutex_unlock()来释放锁,这样其他线程才能再次访问;
pthread_mutex_trylock()来申请锁,这里使用的是非阻塞锁;如果锁现在被别的线程占用则返回非0值,如果没有被占用则返回0;

运行结果:
在这里插入图片描述

(5)死锁

如果多个线程要调用多个对象,则在上锁的时候可能会出现“死锁”。举个例子: A、B两个线程会同时使用到两个共享变量m和n,同时每个变量都有自己相应的锁M和N。 这时A线程首先拿到M锁访问m,接下来他需要拿N锁来访问变量n; 而如果此时B线程拿着N锁等待着M锁的话,就造成了线程“死锁”。

在这里插入图片描述
死锁产生的4个必要条件:

  • 互斥:某种资源一次只允许一个进程访问,即该资源一旦分配给某个进程,其他进程就不能再访问,直到该进程访问结束。
  • 占有且等待:一个进程本身占有资源(一种或多种),同时还有资源未得到满足,正在等待其他进程释放该资源。
  • 不可抢占:别人已经占有了某项资源,你不能因为自己也需要该资源,就去把别人的资源抢过来。
  • 循环等待:存在一个进程链,使得每个进程都占有下一个进程所需的至少一种资源。

当以上四个条件均满足,必然会造成死锁,发生死锁的进程无法进行下去,它们所持有的资源也无法释放。这样会导致CPU的吞吐量下降。所以死锁情况是会浪费系统资源和影响计算机的使用性能的。

产生死锁需要四个条件,那么,只要这四个条件中至少有一个条件得不到满足,就不可能发生死锁了。

破坏“占有且等待”条件:

  • 方法1:所有的进程在开始运行之前,必须一次性地申请其在整个运行过程中所需要的全部资源。
  • 优点:简单易实施且安全。
  • 缺点:因为某项资源不满足,进程无法启动,而其他已经满足了的资源也不会得到利用,严重降低了资源的利用率,造成资源浪费。使进程经常发生饥饿现象。
  • 方法2:该方法是对第一种方法的改进,允许进程只获得运行初期需要的资源,便开始运行,在运行过程中逐步释放掉分配到的已经使用完毕的资源,然后再去请求新的资源。这样的话,资源的利用率会得到提高,也会减少进程的饥饿问题。

破坏“不可抢占”条件:

  • 当一个已经持有了一些资源的进程在提出新的资源请求没有得到满足时,它必须释放已经保持的所有资源,待以后需要使用的时候再重新申请。这就意味着进程已占有的资源会被短暂地释放或者说是被抢占了。该种方法实现起来比较复杂,且代价也比较大。释放已经保持的资源很有可能会导致进程之前的工作实效等,反复的申请和释放资源会导致进程的执行被无限的推迟,这不仅会延长进程的周转周期,还会影响系统的吞吐量。

破坏“循环等待”条件:

  • 可以通过定义资源类型的线性顺序来预防,可将每个资源编号,当一个进程占有编号为i的资源时,那么它下一次申请资源只能申请编号大于i的资源。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值