Part 2 Linux programming:chapter 18:多线程服务器端实现

内容概要:

Web服务器端需要同时向多个用户提供服务,而进程的局限性使得人们开始利用更高效的线程来实现Web服务器端。

18.1 线程的概念

本章主要介绍线程的通用说明,是windows线程的基础。

18.1.1 线程的背景

之前第十章讲了多进程服务器端的实现,之后又利用select/ epoll函数实现I/O复用。
多进程模型与IO复用相比确实有自身的优势,但是就像之前说过的,

  1. 创建进程过程会带来一定的开销(复制工作给操作系统带来沉重负担)
  2. 为了完成进程间数据交换需要特殊的IPC(进程间通信)技术。
  3. 每秒少则数十次,多则上千次的“上下文切换”次穿件进程的最大开销。

上下文切换:
只有一个cpu的系统也可以同时运行多个进程,因为系统将cpu时间分成多个微小的块后,分配给了多个进程。为了分时使用cpu,需要进行“上下文切换”
运行程序前将相应进程信息读入内存中,如果运行进程A后紧接着运行进程B,应该将进程A的信息 移出内存 ,并读入进程B相关信息,这就是上下文切换。
但此时进程A的数据被移动到硬盘中,所以上下文切换很耗时。

因此!为了解决上面的问题,并保持多进程的优点,线程诞生了。
这是为了将进程的各种劣势降至最低限度而设计的一种“轻量级进程”。优点如下:

  1. 线程的创建和上下文切换比进程更快。
  2. 线程间交换数据时无需特殊技术。
18.1.2 线程和进程的差异

先不多说,看图xxxxxxxxxxxx

每个进程间的内存是相互独立的,由《保存全局变量的数据区》《向malloc等函数的动态分配提供空间的堆heap》《函数运行时使用的栈stack》构成。每个进程都拥有这些独立的空间。

xxxxxxxx
而线程的内存空间如上图所示

如果以获得多个代码执行流为主要目的,应该只需要分离栈区域,这样做有如下优势:

  1. 上下文切换时不需要切换数据区和堆(只需要将相关函数信息所在的栈区域进行上下文切换,也就是切换存储空间即可)
  2. 可以利用数据区和堆交换数据(在不同的线程中去访问同一个的变量和数据)

线程为了保持多条代码执行流分离了栈区域,因此有如上图的内存结构。我们可以试着找到进程和线程的不同
进程:在操作系统中,构成单独执行流的单位(常用的win中使用任务管理器可以查看正在运行进程的图形界面)
线程:在进程中,构成单独执行流的单位(线程缩小了执行流的范围,在一个进程中构建了多个执行流)
他们的关系如下图所示:
在这里插入图片描述

18.2 线程创建及运行

以后这个词可能会经常见到
POSIX(Portable Operating System Interface for Computer Environment):适用于计算机环境的可移植操作系统接口。
他是为了提高UNIX系列操作系统间的一致性而制定的API规范,下面介绍的线程创建方法也是POSIX标准为依据的。因此不仅适用于Linux也适用于大部分UNIX系列。

18.2.1 线程的创建和执行流程

线程有单独的执行流,多个线程可以在同一进程中万马奔腾,因此需要单独定义线程的mian函数,还需要请求操作系统在单独的执行流中执行该函数,pthread_create函数能够实现该功能。

#include <pthread.h>

int pthread_create(
	pthread_t* restrict thread, 
	const pthread_attr_t* restrict attr,
	void*(*start_routine)(void*),
	void* restrict arg);
-> 成功时返回0,失败时返回其他值

thread:保存新创建线程ID的变量地址值。与进程相同,也需要区分不同线程的id
attr:	用于传递线程属性的参数,NULL表示创建默认属性的线程。我们暂时只使用NULL,用到其他再说
start_routine:相当于线程的main函数,在单独执行流中执行的函数地址值(函数指针)
arg:	通过第三个参数传递调用函数时,包含传递参数信息的变量地址值

这里的restrict关键字和函数指针的用法还是很重要的,这里先去使用它(用起来还是很简单的)具体用法

thread1.c

#include <stdio.h>
#include <pthread.h>
void* thread_main(void* arg);	// 声明线程main函数


int main(int argc, char *argv[])
{
	/* code */
	pthread_t t_id;	// 声明线程id
	int thread_params = 5;	// 声明线程main函数传入参数
	// 创建线程,id为t_id,默认创建方式,main函数为thread_main,传入参数:thread_params
	if(pthread_create(&t_id, NULL, thread_main, (void*)&thread_params) != 0)
	{
		puts("pthread_create() error");
		return -1;
	}

	sleep(10);	// 为了等待线程的结束,如果不sleep的话,进程结束后其内部的线程一起结束
	puts("end of main");
	return 0;
}

void* thread_main(void* arg)
{
	int i;
	int cnt = *((int*)arg);	// 对传入参数进行类型转换,并取对应地址的值
	for(i = 0; i < cnt; i++){
		sleep(1);
		puts("running thread");
	}
	return NULL;
}

测试结果:
在这里插入图片描述

在编译代码是,添加了-lpthread选项声明需要联接线程库,只有这样才能调用头文件pthread.h中声明的函数。
上方thread1.c流程如图所示:xxxxxxx

在这里我们用sleep函数来等待线程的结束,这是一种预测程序运行时间的执行流控制,这是不可取的!!!复杂的程序中我们根本无法完成这样的操作。

因此我们不会再使用sleep函数来完成这个工作,通常利用下面的函数控制线程执行流。

#include <pthread.h>

int pthread_join(pthread_t thread, void** status);
-> 成功时返回0, 失败时返回其他值

thread:该参数值ID的线程终止后,才会从该函数返回
status:保存线程main函数返回值的指针变量地址(指针的指针/指针的地址)

也就是说:调用pthread_join()函数的进程或线程进入 等待状态,直到参数为ID的线程终止为止。
同时,能够通过status接收线程main函数的返回值。

thread2.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
void* thread_main(void* arg);	// 声明线程main函数


int main(int argc, char *argv[])
{
	/* code */
	pthread_t t_id;	// 声明线程id
	int thread_params = 5;	// 声明线程main函数传入参数
	void* thr_ret;	// 这个参数是用来接收线程返回值的,它与线程返回值的类型应该相同。但是一会传入join函数时需要传入这个参数的指针,也就是void **类型

	// 创建线程,id为t_id,默认创建方式,main函数为thread_main,传入参数:thread_params
	if(pthread_create(&t_id, NULL, thread_main, (void*)&thread_params) != 0)
	{
		puts("pthread_create() error");
		return -1;
	}

	// 等待线程t_id结束
	if(pthread_join(t_id, &thr_ret) != 0){
		puts("pthread_join() error");
		return -1;
	}

	printf("线程返回值:%s \n", (char*)thr_ret);
	free(thr_ret);
	puts("end of main");
	return 0;
}

void* thread_main(void* arg)
{
	int i;
	int cnt = *((int*)arg);	// 对传入参数进行类型转换,并取对应地址的值
	char* msg = (char*)malloc(sizeof(char)*50);
	strcpy(msg, "我是线程啊~~~~~!\n");
	
	for(i = 0; i < cnt; i++){
		sleep(1);
		puts("running thread");
	}
	return (void*)msg;
}

我们来预测一下结果:
主进程中:16行创建线程,23行等待线程结束并接收void**类型返回值,并释放在线程中分配的堆空间。
线程中:声明char* 指针并分配空间,传入数据,等待5s后返回void*类型变量msg

测试结果:xxxx
与预测无异,看一下这个程序的执行流程图
xxxx
可以看到在pthread_join函数执行时,等待线程终止并返回,之后继续进行主进程运行。

18.2.2 可在临界区内调用的函数

看到这个小标题-》啥是临界区啊= - =?先别着急,一会解释

之前在示例中只创建了一个线程,接下来我们要尝试创建多个线程了!!
那么问题来了,最开始我们提到了线程的内存空间结构,其中只有栈空间是各不相同的,数据区(全局变量等)堆空间是共享的。
那如果多个线程同时修改了同一个部分的数据怎么办????
谁先谁后,会不会引发什么奇奇怪怪的东西?????

答案是:会的!
怎么解决~?现在学的这些还解决不了这个问题,我们继续学习。

这个问题中涉及到的就是临界区的概念:多个线程同时执行这部分代码时,可能引起问题的区域。(位于线程执行流中,多个同时!)

根据临界区是否会引起问题,函数分为以下两类:

  1. 线程安全函数(Thread-safe function)
  2. 非线程安全函数(Thread-safe function)

线程安全函数:多个线程同时调用也不会引发问题;反之,非安全会引发问题。

大多数标准函数都是线程安全函数,我们不用自己区分线程安全函数和非线程安全函数,平台在定义非线程安全函数时,提供了具有相同功能的线程安全函数。
例如:第八章中的

struct hostent* gethostbyname(const char* hostname);

它就不是线程安全函数。
但系统提供了同一功能的:

struct hostent* gethostbyname_r(const char* name, 
		struct hostent* result, char* buffer, int buflen, 
		int* h_errnop);

线程安全函数的名称后缀通常为_r(Win下不同),因此,如果我们多个线程同时访问上面说的gethostbyname函数时,应该调用函数gethostbyname_r

但是这样的调用方式太复杂了,我们不可能记住每个非线程安全函数的对应线程安全函数的API接口,因此我们采用另一种方便的方式,可以达到自动将非线程安全函数转变成线程安全函数
声明头文件前定义_REENTRANT宏”(entrant:进入者 reentrant:(再进去的)可重入的)

如何完成这个操作呢?无需为了上述宏定义特意添加#define语句,只需在编译时添加-D_REENTRANT选项定义宏。

18.2.3 工作(Worker)线程模式(示例)

这里我们将上面说到的所有内容通过示例来展示

示例说明:
通过2个线程计算1到10的和
其中一个线程计算1-5
另一个线程计算6-10
main函数只负责输出运算结果
这种编程模式称为工作线程模型,main中对工作的线程进行管理
看一下流程图,接下来敲代码
在这里插入图片描述
thread3.c

`

#include <stdio.h>
#include <pthread.h>
void* thread_summation(void* arg);	// 声明线程main函数
int sum = 0;						// 声明全局变量sum(一会用来在线程中累计求和的变量)

int main(int argc, char *argv[])
{
	/* code */
	pthread_t t_id1, t_id2;	// 声明线程id

	int range1[] = {1,5};	// 声明pthread_create函数中的参数-》线程main函数中的传入参数
	int range2[] = {6,10};
	// 创建线程,id为t_id,默认创建方式,main函数为thread_main,传入参数:thread_params
	if(pthread_create(&t_id1, NULL, thread_summation, (void*)range1) != 0)
	{
		puts("pthread_create() error");
		return -1;
	}
	if(pthread_create(&t_id2, NULL, thread_summation, (void*)range2) != 0){
		puts("pthread_create() error");
		return -1;
	}
	// 等待线程t_id结束
	if(pthread_join(t_id1, NULL) != 0){
		puts("pthread_join() error");
		return -1;
	}
	if(pthread_join(t_id2, NULL) != 0){
		puts("pthread_join() error");
	}
	printf("result : %d\n", sum);
	puts("end of main");
	return 0;
}

void* thread_summation(void* arg)
{
	int start, end;
	start = ((int*)arg)[0];
	end = ((int*)arg)[1];
	while(start <= end){
		sum += start;
		start++;
	}
	return NULL;
}

测试结果:
在这里插入图片描述
从结果来看,效果很好,成功计算出了我们想要的结果,两个线程在计算过程中,都访问并修改了sum变量的数值,并且没有影响结果。

然而 这真的没有什么问题吗?我们来个更加复杂例子,说起更复杂,并不是说编程思路上的难度,我们想试试在很多很多线程同时运行时,一起访问我们的全局变量时,会不会产生问题,也就是刚刚提到的临界区相关错误?

我们再看一个例子
要求:

  1. 创建50个进程执行thread_inc函数的代码,函数中循环50000000次,每次num -= 1
  2. 创建50个进程执行thread_des函数的代码,函数中循环50000000次,每次num += 1
  3. 在主进程中,交替创建这个100个线程。(别忘了利用thread_join函数等待进程结束
    thread4.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>

#define NUM_THREAD 100 			// 定义线程数量,一会用于

void* thread_inc(void* arg);	// 声明线程main函数
void* thread_des(void* arg);	// 声明线程main函数
long long sum = 0;						// 声明全局变量sum long long 是64位整型
int main(int argc, char* argv[])
{
	/* code */
	pthread_t thread_id[NUM_THREAD];	// 声明线程id数组

	// 创建线程,利用for循环建立100个线程
	for(int i = 0; i < NUM_THREAD; i++)
	{
		if(i % 2){	// i除以2 余数不为0:i为奇数
			if(pthread_create(&(thread_id[i]), NULL, thread_inc, NULL) != 0){
				puts("pthread_create() error");
				return -1;
			}

		}
		else{		// i为偶数
			if(pthread_create(&(thread_id[i]), NULL, thread_inc, NULL) != 0){
				puts("pthread_create() error");
				return -1;
			}
		}
	}

	// 等待线程结束
	for(int i = 0; i < NUM_THREAD; i++){
		pthread_join(thread_id[i],NULL);	// 没有返回值
	}
	printf("result : %lld\n", sum);
	puts("end of main");
	return 0;
}

void* thread_inc(void* arg)
{
	for(int i = 0; i < 50000000; i++){
		sum += 1;
	}
	return NULL;
}

void* thread_des(void* arg)
{
	for(int i = 0; i < 50000000; i++){
		sum -= 1;
	}
	return NULL;
}


测试结果:
在这里插入图片描述
可以看到,如果按照正常的逻辑来说,应该得到的结果为0才对,但是我们看到每次运行出来的结果都不同。

这是为啥呢?一定是真正运行的过程和我们想的不一样,多个线程在同时访问临界区数据的时候,出现了一些暂时不知道的错误,导致每次输出的结果都不同。

18.3 线程存在的问题和临界区

我们在这里分析一下上面thread4.c中出现的问题以及原因。

18.3.1 多个线程访问同一变量是问题的关键

上面示例中,两个线程同时访问全局变量num(访问指的是更改值)可能会产生问题,但是,虽然示例中访问的是全局变量,但是并非全局变量引发的问题。任何内存空间,只要被同时访问都有可能发生问题。

“在之前章节中我们学过,cpu个数代表同时处理任务能力,假设只有一个cpu的情况下,各个线程在处理时是分时使用cpu的,那应该不会出现上面说的同时访问变量的情况啊?????”
“虽然如此,但是大家有木有想过,如果一个任务在尚未执行完的时候,cpu资源切换了,这会导致什么= =?”

我们希望实行的流程像下面图示:(假设两个线程都执行对sum变量的+=1操作)
在这里插入图片描述 在这里插入图片描述
线程1将变量num值增加到100,线程2再访问num,变量中保存着我们想要的101
值的增加需要通过CPU运算完成,变量num中的值不会自动增加。线程1首先读取变量值并将其传递到CPU,获得 +1之后的结果100,最后把结构写回变量num中,这样num就保存了100。

然而事实可能并非如此,看下图的过程
在这里插入图片描述 在这里插入图片描述 在这里插入图片描述
线程1读取变量num的值并完成+1运算,只是最后结果尚未写入变量num。
接下来正要将100保存到变量num值中,但是执行该操作前,执行流程跳转到了线程2,线程2完成了+1运算,并将+1之后的结果写入变量。(如中间图所示)
可以看到,变量num的值未被线程1加到100,因此线程2读到的变量值为99,所以线程2将num的值改为100,还剩下线程1将运算后的值写入变量num的操作(如右图)
很可惜的是,这时只是重复写入了100而已。因此出现了结果的错误。

因此,线程访问变量num时应该阻止其他线程的访问,直到线程1完成运算,这就是同步。这时非常重要的。

18.3.2 临界区位置

临界区的定义我们在上面曾经提到过一次:
函数内同时运行多个线程时引起问题的多条语句构成的代码块

可以理解的是,全局变量sum的声明区域并非临界区,因为它不是引起问题的语句。临界区通常位于线程运行的函数内部,在thread4.c中,连个线程函数如下所示:

void* thread_inc(void* arg)
{
	for(int i = 0; i < 50000000; i++){
		sum += 1;	// 临界区
	}
	return NULL;
}

void* thread_des(void* arg)
{
	for(int i = 0; i < 50000000; i++){
		sum -= 1;	// 临界区
	}
	return NULL;
}

可见,代码注释中表明的临界区并非是num本身,而是访问num的语句。

18.4 线程同步

这里我们讨论解决问题的方法:线程同步

18.4.1 线程同步的两面性

线程同步用于解决线程访问顺序引发的问题,需要同步的情况可以从如下两个方面考虑

  1. 同时访问 同一内存空间时发生的情况(上面的例子中的情况)
  2. 需要制定访问 同一内存空间的线程 执行顺序的情况(下面说的解决方案)

这里讲讲第二种情况,这是控制线程执行顺序的相关内容。
假设有A、B两个线程,A负责向指定内存空间写入(保存)数据,B负责取走该数据。
这种情况下, A首先应该访问约定的内存空间并保存数据,如果B先访问并取走据,将导致错误结果。
像这种需要控制执行顺序的情况需要使用同步技术

互斥量(Mutex)和信号量(Semaphore是两种比较好用的同步技术

18.4.2 同步方法一:互斥量(Mutual Exclusion)

互斥表示不允许多个线程同时访问。互斥量主要用于解决线程同步访问的问题。类似于将临界区视为洗手间。洗手间的规则如下:

  1. 为了保护个人隐私,进入时上锁,出来时开锁
  2. 如果有人使用中,其他人门外等待
  3. 等待人数很多,必须排队进入

临界区的使用规则同样如上,这里其实2和3已经在不知不觉中由底层工作者完成了,剩下的只有第一条我们没有涉及到。

这就是锁机制,而互斥量就是一把优秀的锁,下面我们看互斥量的创建和销毁(也就是上锁和开锁)

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t* mutex, 
		const pthread_mutexattr_t* attr);

int pthread_mutex_destroy(pthread_mutex_t* mutex);
-> 成功时返回0, 失败时返回其他值

mutex:创建互斥量时传递 保存互斥量的地址值,销毁时传递需要销毁的互斥量地址值
attr:传递即将创建的互斥量属性,没有特别需要指定的属性时传递NULL(暂时都传递NULL有特殊情况再说)

从上面的函数看出,为了创建相当于锁系统的互斥量,需要声明如下的pthread_mutex_t型变量:

pthread_mutex_t mutex;

该变量的地址将传递给pthread_mutex_init函数,用来保存操作系统创建的互斥量(锁系统)。pthread_mutex_destroy函数同样需要该信息。

如果不需要向pthread_mutex_init函数中的attr参数传递信息,也就是传递NULL时,可以用下面的宏进行声明,这样可以省去调用init函数初始化的语句

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

但是尽量使用pthread_mutex_init函数进行初始化,因为宏定义进行初始化时很难发现错误。

下面介绍利用互斥量锁住 & 释放临界区时使用的函数。

#include <pthread.h>
// 上锁函数
int pthread_mutex_lock(pthread_mutex_t* mutex);
// 解锁函数
int pthread_mutex_unlock(pthread_mutex_t* mutex);
-> 成功时返回0,失败时返回其他值。

解释一下函数的作用:

  • pthread_mutex_lock:上互斥锁,禁止除当前线程外的其他线程使用资源。
    调用该函数时,发现有其他进程已进入临界区,则该函数不会返回,直到里面的线程调用pthread_mutex_unlock函数退出临界区为止。
    也就是说,其他线程退出临界区之前,当前线程一直处在阻塞状态。

  • pthread_mutex_unlock:解除互斥锁,允许其他线程访问资源。

总体的逻辑是:在主线程中创建互斥量,在子线程中使用互斥量,最后在主线程中销毁互斥量

创建好互斥量后,可以使用如下的结构保护临界区

pthread_mutex_lock(&mutex);
// 临界区开始
....
// 临界区结束
pthread_mutex_unlock(&mutex);

简言之,利用lock和unlock函数围住临界区的两端。此时互斥量相当于一把锁,组织多个线程同时访问。当线程退出临界区时,如果忘记调用unlock函数,那么其他线程无法访问这部分资源,所以会一直阻塞,这就是死锁

下面利用thread4.c中的代码,我们使用互斥量,搞定当时出现的问题。
mutex.c

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <pthread.h>

#define NUM_THREAD 100 			// 定义线程数量,一会用于

void* thread_inc(void* arg);	// 声明线程main函数
void* thread_des(void* arg);	// 声明线程main函数
long long sum = 0;				// 声明全局变量sum long long 是64位整型
pthread_mutex_t mutex;			// 创建线程之前进行互斥量创建
int main(int argc, char* argv[])
{
	/* code */
	pthread_t thread_id[NUM_THREAD];	// 声明线程id数组

	// 创建线程之前进行互斥量初始化(这里是新添加的代码,进行互斥量创建)
	pthread_mutex_init(&mutex, NULL);

	// 创建线程,利用for循环建立100个线程
	for(int i = 0; i < NUM_THREAD; i++)
	{
		if(i % 2){	// i除以2 余数不为0:i为奇数
			if(pthread_create(&(thread_id[i]), NULL, thread_inc, NULL) != 0){
				puts("pthread_create() error");
				return -1;
			}

		}
		else{		// i为偶数
			if(pthread_create(&(thread_id[i]), NULL, thread_inc, NULL) != 0){
				puts("pthread_create() error");
				return -1;
			}
		}
	}

	// 等待线程结束
	for(int i = 0; i < NUM_THREAD; i++){
		pthread_join(thread_id[i],NULL);	// 没有返回值
	}
	printf("result : %lld\n", sum);
	// 释放互斥量(这里是新创建的代码,对应用于互斥量销毁)
	pthread_mutex_destroy(&mutex);
	puts("end of main");
	return 0;
}

void* thread_inc(void* arg)
{
	for(int i = 0; i < 50000000; i++){
		pthread_mutex_lock(&mutex);
		sum += 1;		// 这里是临界区,使用了互斥锁将这部分代码围住
		pthread_mutex_unlock(&mutex);

	}
	return NULL;
}

void* thread_des(void* arg)
{	
	pthread_mutex_lock(&mutex);	
	for(int i = 0; i < 50000000; i++){
		sum -= 1;
	}
	pthread_mutex_unlock(&mutex);
	return NULL;
}


测试结果:
在这里插入图片描述

上述代码中,两个线程处理函数采用了不同的互斥锁/临界区 限定范围
我们先看这个

void* thread_des(void* arg)
{	
	pthread_mutex_lock(&mutex);	
	for(int i = 0; i < 50000000; i++){
		sum -= 1;
	}
	pthread_mutex_unlock(&mutex);
	return NULL;
}

这个函数采用的是范围较大的临界区划分,这样做的优点是:
最大限度减少互斥量lock、unlock函数的调用次数。
缺点是:
num值减少到-500000000之前是不允许其他线程访问的。(0的个数不知道对不就这个意思)

在上述的代码mutex.c中,thread_incthread_des函数多调用了49999999次互斥量lock和unlock函数。

18.4.3 同步方法2:信号量(Semaphore)

信号量与互斥量非常相似,我们这里只设计利用二进制信号量0和1完成控制线程顺序的同步方法。
信号量创建以及销毁的方法

#include <semaphore.h>

int sem_init(sem_t* sem, int pshared, unsigned int value);
int sem_destroy(sem_t* sem);
-> 成功时返回0, 失败时返回其他值

sem:创建信号量时传递保存信号量的 变量地址。销毁时传递需要销毁的信号量变量地址值
pshared:传递其他值时,创建可有多个进程共享的信号量;
		传递0时,创建只允许一个进程内部使用的信号量。
		我们需要完成同一进程内的线程同步,因此传递0.
value:指定新创建的信号量初始值。

上面的sem_init函数中的pshared我们默认传递0即可。
接下来介绍信号量中相当于互斥量的lockunlock函数

#include <semaphore.h>

int sem_post(sem_t* sem);
int sem_wait(sem_t* sem);

->成功时返回0,失败时返回其他值

sem:传递保存信号量读取值的变量地址值,传递给sem_post时信号量增加1,
传递给sem_wait函数时信号量-1

函数说明:
调用sem_init函数时,操作系统将创建信号量对象,此对象中记录“信号量值”
整数。该值在调用sem_post函数时增加1 ,调用sem_wait函数时-1.

但是信号量值不能小于0,因此在信号量为0的情况下调用sem_wait函数,调用函数的线程将进入阻塞状态(函数未返回)。如果有其他线程调用sem_post函数,信号量的值会变成1,这时原本阻塞的线程可以通过 将该信号量重新变为0而跳出阻塞状态,进而完成临界区的同步操作。

sem_wait(&sem);	// 将sem变为0
// 临界区开始
...
// 临界区结束
sem_post(&sem);	// 将sem变为1

上面的结构中,调用sem_wait函数时,将原本为1的sem置为0,并顺利返回。如果sem为1时,则此时阻塞。在sem_post函数调用并将sem置为1之前,没有其他线程能够进入临界区。

信号量的值在 0 1 之间跳转,这种特性的机制叫作“二进制信号量”
下面来看关于控制访问顺序的同步

情景:线程A从用户输入得到值后存入全局变量num中,此时线程B将值取走并累加。这个过程一共进行5次,完成后输出总和并退出程序。

semaphore.c

#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>


void* read(void* arg);	// 声明线程main函数
void* acco(void* arg);	// 声明线程main函数

static sem_t sem_one;	// 信号量1
static sem_t sem_two;	// 信号量2 
static int num;			// 用于存储输入数据
static int sum = 0;			// 存储最终累加结果

int main(int argc, char* argv[])
{
	/* code */
	pthread_t id_t1, id_t2;	// 声明线程id

	// 创建线程之前进行互斥量创建 和 初始化
	// pthread_mutex_t mutex;
	// pthread_mutex_init(&mutex, NULL);

	//创建进程前,先初始化信号量,一会要用在线程函数中的信号量,一个初始化为1 一个为0
	sem_init(&sem_one, 0, 0);	// 只允许一个进程使用:第二个参数为0
	sem_init(&sem_two, 0, 1);

	// 创建2个线程,一个用来接收数据并存储到num中,一个用来读取num数值并累计求和

	if(pthread_create(&id_t1, NULL, read, NULL) != 0){
		puts("pthread_create() error");
		return -1;
	}
	if(pthread_create(&id_t2, NULL, acco, NULL) != 0){
		puts("pthread_create() error");
		return -1;
	}
		

	// 等待线程结束

	pthread_join(id_t1,NULL);	// 没有返回值
	pthread_join(id_t2,NULL);	// 没有返回值

	printf("5次累加结束,result : %d\n", sum);

	// 销毁信号量
	sem_destroy(&sem_one);
	sem_destroy(&sem_two);

	puts("end of main");
	return 0;
}

void* read(void* arg)	// 两个函数的逻辑设计是难点,写入一个,读取一个,也就是说,每当写入之后read线程进入阻塞状态等待读取结束
{	
	for(int i = 0;i < 5;i++)
	{
		fputs("Input num:",stdout);	
		sem_wait(&sem_two);		//先写入 sem_two  1->0
		scanf("%d",&num);
		sem_post(&sem_one);		//写入结束 sem_one 0->1 
	}
	
	return NULL;
}

void* acco(void* arg)
{	
	for(int i = 0; i < 5;i++)
	{
		sem_wait(&sem_one);			// 写入之后进行读取 sem_one 1->0
		sum += num;
		sem_post(&sem_two);			// 读取结束,sem_two 0->1 可以再次写入
		printf("当前累加第%d个数字,结果为%d\n", i+1, sum);
	}

	return NULL;
}


测试结果:
在这里插入图片描述
为什么需要两个信号量?????(谁知道这俩进程谁先运行=- =)
如果用一个信号量,那就代表着要用一个信号量同时控制两个线程,sem_two为了防止调用acco函数的线程,尚未取走数据的时候,调用read函数的线程覆盖了原值。sem_one防止read函数的线程写入新值,acco函数取走数据

18.5 线程的销毁和多线程并发服务器端的实现

我们之前讨论了线程的创建和控制,然而,线程并非是在首次调用的线程main函数返回时自动销毁
因此必须对线程进行销毁,否则由线程创建的内存空间将一直存在。

有两种方法,其中之一我们在上面见过:

  1. 调用pthread_join函数(上面一直用的这个)
  2. 调用pthread_detach函数

之前我们使用过pthread_join函数,该函数被调用时,不仅会等待线程终止,还会引导线程销毁。 但是该函数的问题是线程终止前,调用该函数的线程将进入阻塞状态。

因此 常常使用pthread_detach函数来引导线程销毁。(有木有等待进程结束的作用呢?这个作用可是很重要的呀,因为如果不阻塞,进程直接结束了咋办。)

#include <pthread.h>

int pthread_detach(pthread_t thread);
-> 成功时返回0,失败时返回其他值。

thread:终止的同时需要销毁的线程id

调用pthread_detach函数不会引起线程终止或进入折射状态,可以通过该函数引导销毁线程创建的内存空间(不阻塞怎么等待线程结束呀。。

调用该函数后,不能再针对相应线程调用pthread_join函数,会更改线程内部的设置,使得线程在结束时自动回收内存空间。(具体书中没说,可以自行百度下面是我得到的结论)

创建一个线程默认的状态是joinable, 如果一个线程结束运行但没有被join,则它的状态类似于进程中的Zombie Process,即还有一部分资源没有被回收(退出状态码),
所以创建线程者应该调用pthread_join来等待线程运行结束,并可得到线程的退出代码,回收其资源(类似于wait,waitpid)

但是调用pthread_join(pthread_id)后,
如果该线程没有运行结束,调用者会被阻塞,在有些情况下我们并不希望如此,比如在Web服务器中当主线程为每个新来的链接创建一个子线程进行处理的时候,主线程并不希望因为调用pthread_join而阻塞(因为还要继续处理之后到来的链接)

这时可以在子线程中加入代码 pthread_detach(pthread_self())
或者父线程调用 pthread_detach(thread_id)(非阻塞,可立即返回)
这将该子线程的状态设置为detached,则该线程运行结束后会自动释放所有资源。

18.5.1 多线程并发服务器端的实现

本节介绍多个客户端之间可以交换信息的简单聊天程序,目的是复习线程的使用方法及同步的处理方法。(注意临界区的处理方式)
代码量比较大,因此减少异常处理的代码,读起来更清晰。
首先搞定服务器端代码:char_serv.c

#include <stdio.h>
#include <pthread.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <string.h>

// 宏定义
#define BUF_SIZE 100
#define MAX_CLNT 256	// 最大连接客户端数目

// 定义全局变量
	// 套接字使用变量
	//// 线程使用变量
int clnt_cnt = 0;
int clnt_socks[MAX_CLNT];	// 存储各个客户端套接字
pthread_mutex_t mutex;	// 声明互斥量,一会用在临界区中,防止多线程造成数据混乱

// 函数声明
// 线程main函数,用来进行客户端连接和传输数据操作
void* handle_clnt(void* arg);	
// 异常处理函数 
void error_handling(char* message);
// 数据发送处理函数
void write_msgtoall(char* msg, int str_len);

int main(int argc, char *argv[])
{
	/* code */
	if(argc!=2) {
		printf("Usage : %s <port>\n", argv[0]);
		exit(1);
	}
	// 首先定义套接字相关变量
	int serv_sock, clnt_sock;
	struct sockaddr_in serv_addr, clnt_addr;
	socklen_t clnt_adr_sz;

	// 定义一下线程用到的变量
	pthread_t t_id;		// 创建线程时传入的参数,不需要我们手动更新,每一个新的线程会改变。

	// 套接字一条龙,先搞定服务器套接字相关的
	serv_sock = socket(PF_INET, SOCK_STREAM, 0);
	memset(&serv_addr, 0, sizeof(serv_addr));
	serv_addr.sin_family = AF_INET;
	serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
	serv_addr.sin_port = htons(atoi(argv[1]));

	if(bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1){
		error_handling("bind error!");
	}
	if(listen(serv_sock, 5) == -1){
		error_handling("listen() error");
	}

	/* 服务器端相关设置搞定,下面进入循环 */
	// 循环中受理客户端连接请求,并搞定相关变量的更新,具体数据传输在线程中进行
	while(1)
	{	// 接受请求
		clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_adr_sz);
		// 更新变量clnt_socks中的客户端套接字信息,但是由于这个变量在线程中需要用到,
		// 因此需要划分为临界区,这里使用互斥量的方法
		// 这个部分是因为我们要在服务器中对所有的客户端发送数据,所以需要保存全部
		pthread_mutex_lock(&mutex);
		clnt_socks[clnt_cnt++] = clnt_sock;
		pthread_mutex_unlock(&mutex);

		// 创建线程为客户端提供服务
		pthread_create(&t_id, NULL, handle_clnt, (void*)&clnt_sock);
		printf("before pthread_detach t_id: %u\n", t_id);
		pthread_detach(t_id);
		printf("after pthread_detach t_id: %u\n", t_id);
		printf("Connected client IP: %s \n", inet_ntoa(clnt_addr.sin_addr));
		
	}
	close(serv_sock);
	return 0;
}

void* handle_clnt(void* arg)
{
	// 在线程需要实现:将从client读取到的数据发送到所有的client,从而实现聊天功能。
	// 定义必须的变量
	int clnt_sock = *((int*)arg);		// 本线程连接的客户端套接字
	int str_len = 0;					// 读取数据字节数
	char msg[BUF_SIZE];					// 读取的缓存
	
	// 调用read函数,读取当前线程所连接的客户端传来的数据,不断循环提供服务
	while((str_len = read(clnt_sock, msg, sizeof(msg))) != 0){
		write_msgtoall(msg, str_len);	// 向所有客户端发送消息
	}

	// while 循环结束代表着当前线程结束服务,需要将存储着客户端套接字的全局变量中的数据更新,注意需要考虑临界区的问题
	pthread_mutex_lock(&mutex);
	//利用for循环遍历全局变量 clnt_socks中套接字文件描述符,找到了就删掉。

	for(int i = 0; i < clnt_cnt;i++)
	{
		if(clnt_socks[i] == clnt_sock){	// 找到了这个客户端套接字
			for(int j = i; j < clnt_cnt - 1;j++){
				clnt_socks[j] = clnt_socks[j+1];
			}
			break;
		}
	}
	clnt_cnt--;
	pthread_mutex_unlock(&mutex);

	close(clnt_sock);
	return NULL;
}

void write_msgtoall(char* msg, int str_len)
{
	// 在这里向所有客户端发送信息,达到群聊天效果, 由于其中涉及到线程相关全局变量,因此设置为临界区,使用互斥锁来设置临界区
	pthread_mutex_lock(&mutex);
	for(int i = 0;i < clnt_cnt;i++)
	{
		write(clnt_socks[i], msg, str_len);
	}
	pthread_mutex_unlock(&mutex);
}

void error_handling(char* msg)
{
	fputs(msg, stdout);
	fputc('\n',stdout);
	exit(1);
}

从上面的代码中就可以看出,我们并不用担心由于pthread_detach函数不阻塞带来的焦虑(担心主线程结束),因为在主线程中会一直进行while循环等待新的客户端来进行请求。

下面是客户端代码:char_clnt.c

#include <stdio.h>
#include <pthread.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <string.h>

// 宏定义
#define BUF_SIZE 100
#define NAME_SIZE 20	

void * send_msg(void * arg);
void * recv_msg(void * arg);
// 异常处理函数 
void error_handling(char* message);

char name[NAME_SIZE]="[DEFAULT]";
char msg[BUF_SIZE];

int main(int argc, char *argv[])
{
	/* code */
	if(argc!=4) {
		printf("Usage : %s <IP> <port> <name>\n", argv[0]);
		exit(1);
	 }
	// 首先定义套接字相关变量
	int sock;
	struct sockaddr_in serv_addr;

	// 定义一下线程用到的变量
	pthread_t send_thread, recv_thread;		//定义两个线程id		

	sprintf(name, "【%s】", argv[3]);
	// 套接字一条龙,先搞定服务器套接字相关的
	sock = socket(PF_INET, SOCK_STREAM, 0);
	memset(&serv_addr, 0, sizeof(serv_addr));
	serv_addr.sin_family = AF_INET;
	serv_addr.sin_addr.s_addr = inet_addr(argv[1]);
	serv_addr.sin_port = htons(atoi(argv[2]));

	if(connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1){
		error_handling("connect error!");
	}

	// 搞定了套接字相关的,下面开始创建两个线程,分别用来向服务器端发送数据,从服务器端接收数据

	pthread_create(&send_thread, NULL, send_msg, (void*)&sock);
	pthread_create(&recv_thread, NULL, recv_msg, (void*)&sock);

	//pthread_detach(send_thread);
	//pthread_detach(recv_thread);
	pthread_join(send_thread, NULL);// 数据发送线程
	pthread_join(recv_thread, NULL);// 数据接收线程
	

	close(sock);
	return 0;
}

void* send_msg(void* arg)
{	// 发送数据,从标准输入接收,打印的数据格式为 名字+ 信息,所以str大小为下方所示
	int sock = *((int*)arg);
	char name_msg[NAME_SIZE + BUF_SIZE];
	while(1)
	{
		fgets(msg, BUF_SIZE, stdin);
		sprintf(name_msg, "%s %s", name, msg);
		write(sock, name_msg, strlen(name_msg));
	}
	return NULL;
}

void* recv_msg(void* arg)
{
	int sock=*((int*)arg);
	char name_msg[NAME_SIZE+BUF_SIZE];
	int str_len;
	while(1)
	{
		str_len=read(sock, name_msg, NAME_SIZE+BUF_SIZE-1);
		if(str_len==-1) 
			return (void*)-1;
		name_msg[str_len]=0;
		fputs(name_msg, stdout);
	}
	return NULL;
}

void error_handling(char* msg)
{
	fputs(msg, stdout);
	fputc('\n',stdout);
	exit(1);
}

同理:在这里我们使用了pthread_join函数,因为我们并不担心阻塞,相反,我们希望在这里阻塞,因为客户端可以一直接受或发送数据,不然主线程就要返回了。
测试结果:
在这里插入图片描述
这里涉及了sprintf函数的使用
但是重点是

  • 看临界区的划分方法
  • 服务器端创建线程采用pthread_detach函数销毁线程内存
  • 客户端创建创建线程采用pthread_join函数等待线程返回并销毁
    后面两点可以参考这篇文章
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值