温故Linux后端编程(三):线程那些事儿

举报
看,未来 发表于 2021/02/06 02:01:04 2021/02/06
【摘要】 文章目录 前言摘要线程什么是线程使用线程的优势线程与进程千丝万缕的纠缠线程间资源共享情况使用线程的弊端 线程管理(Thread Managment)创建线程获取当前线程id判断俩线程是否相等连接(Joining)和分离(Detaching)线程线程属性 互斥量互斥量存在的意义互斥锁原语参数释义互斥量使用死锁锁种乐观锁悲观锁乐观锁 VS 悲观锁自旋锁 && 互...

在这里插入图片描述

前言

不知不觉,就到大三了。
不知不觉,就要开始找暑期实习了。
温故而知新嘛。(数据结构复习两天发现不对,我还是更喜欢这个。) 所以就来了。

摘要

在多处理器共享内存的架构中(如:对称多处理系统SMP),线程可以用于实现程序的并行性。历史上硬件销售商实现了各种私有版本的多线程库,使得软件开发者不得不关心它的移植性。对于UNIX系统,IEEE POSIX 1003.1标准定义了一个C语言多线程编程接口。依附于该标准的实现被称为POSIX theads 或 Pthreads。

该教程介绍了Pthreads的概念、动机和设计思想。内容包含了Pthreads API主要的三大类函数:线程管理(Thread Managment)、互斥量(Mutex Variables)和条件变量(Condition Variables)。向刚开始学习Pthreads的程序员提供了演示例程。

适于:刚开始学习使用线程实现并行程序设计;对于C并行程序设计有基本了解。

线程

都说知其然,知其所以然。
不知道,我们专业的要求是这样的。

什么是线程

官方话就是:是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

1、提高程序的并发性
2、开销小,不需要重新分配内存
3、通信和共享数据方便

  
 
  • 1
  • 2
  • 3

在这里插入图片描述


使用线程的优势

  • 在同一个进程中的所有线程共享同样的地址空间。较于进程间的通信,在许多情况下线程间的通信效率比较高,且易于使用。
    较于没有使用线程的程序,使用线程的应用程序有潜在的性能增益和实际的优点:

  • CPU使用I/O交叠工作:例如,一个程序可能有一个需要较长时间的I/O操作,当一个线程等待I/O系统调用完成时,CPU可以被其它线程使用。

  • 优先/实时调度:比较重要的任务可以被调度,替换或者中断较低优先级的任务。

  • 异步事件处理:频率和持续时间不确定的任务可以交错。例如,web服务器可以同时为前一个请求传输数据和管理新请求。

  • Pthreads没有中间的内存复制,因为线程和一个进程共享同样的地址空间。没有数据传输。变成cache-to-CPU或memory-to-CPU的带宽(最坏情况),速度是相当的快。

劣势啊,劣势也很明显,毁誉参半,后面再说。


线程与进程千丝万缕的纠缠

1)线程又被叫做轻量级进程,也有PCB,创建线程使用的底层函数和进程是一样的,都是clone。
(2)从内核里看线程和进程是一样的,都有各自不同的PCB,但是PCB指向的内存资源的三级页表是不同的。
(3)进程可以蜕变成线程,进程也可以说是主线程,就是高速路的主干道。
(4)在Linux下,线程是最小的执行单位,进程是最小的分配资源单位。

  
 
  • 1
  • 2
  • 3
  • 4

线程间资源共享情况

⑴共享资源

1、文件描述符表
2、每种信号的处理方式
3、当前工作目录
4、用户ID和组ID
5、内存地址空间

  
 
  • 1
  • 2
  • 3
  • 4
  • 5

⑵非共享资源

1、线程id
2、处理器现场和栈指针
3、独立的栈空间
4、errno变量
5、信号屏蔽字
6、调度优先级

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

使用线程的弊端

1、线程不稳定(这个是真的不稳定,后面会专门出一篇“可重入函数对线程的影响”,因为现在还没整理好那块儿)
2、线程调试困难(这个是真的头疼,难以调试的东西,目前我只有一个“段错误,核心已转储”可以用用,关键是错误难以复现,很难,很难)
3、线程无法使用Unix经典事件,如信号(这个反正我也没用过,管它)

  
 
  • 1
  • 2
  • 3

例如:假设你的程序创建了几个线程,每一个调用相同的库函数:

这个库函数存取/修改了一个全局结构或内存中的位置。
当每个线程调用这个函数时,可能同时去修改这个全局结构活内存位置。
如果函数没有使用同步机制去阻止数据破坏,这时,就不是线程安全的了。

如果你不是100%确定外部库函数是线程安全的,自己负责所可能引发的问题。 
建议:小心使用库或者对象,当不能明确确定是否是线程安全的。若有疑虑,假设其不是线程安全的直到得以证明。
可以通过不断地使用不确定的函数找出问题所在。

  
 
  • 1
  • 2
  • 3

看一下这篇(过几天会重写):可重入函数对于线程安全的意义


线程管理(Thread Managment)

创建线程

#include<pthread.h>

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

/*
参数释义:
thread:这是一个传出参数,传递一个pthread_t变量进来,用以保存新线程的tid(线程id)
attr:线程属性设置,NULL代表使用默认属性(注(1))
(*start_routine)(void *):函数指针,指向新线程应该指向的函数模块
arg:老熟了,给前面那个函数传参用的,不传就写NULL

返回值:成功返回0.,失败返回错误号,错误号,错误号,前面说过errno不共享的。(线程里返回值统一这样的,后面不提了)

注(1):创建线程时,没什么特殊情况我们都是使用默认属性的,不过有时候需要做一些特殊处理,碧如调整优先级啊这些的。
*/

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

Q:怎样安全地向一个新创建的线程传递数据?
A:确保所传递的数据是线程安全的(不能被其他线程修改)。下面三个例子演示了那个应该和那个不应该。

代码演示:

// Example Code - Pthread Creation and Termination  

#include <pthread.h> 
#include <stdio.h> 
#define NUM_THREADS 5 

void *PrintHello(void *thread_id) 
{ int tid; tid = (int)thread_id; printf("Hello World! It's me, thread #%d!\n", tid); pthread_exit(NULL); 
} 

int main(int argc, char *argv[]) 
{ pthread_t threads[NUM_THREADS]; int rc, t; for(t=0; t<NUM_THREADS; t++){ printf("In main: creating thread %d\n", t); rc = pthread_create(&threads[t], NULL, PrintHello, (void *)t); if (rc){ printf("ERROR; return code from pthread_create() is %d\n", rc); exit(-1); } } pthread_exit(NULL); 
} 

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

接下来演示线程安全:

//下面的代码片段演示了如何向一个线程传递一个简单的整数。
//主线程为每一个线程使用一个唯一的数据结构,确保每个线程传递的参数是完整的。
int *taskids[NUM_THREADS]; 

for(t=0; t<NUM_THREADS; t++) 
{ taskids[t] = (int *) malloc(sizeof(int)); *taskids[t] = t; printf("Creating thread %d\n", t); rc = pthread_create(&threads[t], NULL, PrintHello,(void *) taskids[t]); ... 
} 

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
//例子展示了用结构体向线程设置/传递参数。每个线程获得一个唯一的结构体实例。 

struct thread_data{ int  thread_id; int  sum; char *message; 
}; 

struct thread_data thread_data_array[NUM_THREADS]; 

void *PrintHello(void *threadarg) 
{ struct thread_data *my_data; ... my_data = (struct thread_data *)threadarg; taskid = my_data->thread_id; sum = my_data->sum; hello_msg = my_data->message; ... 
} 

int main (int argc, char *argv[]) 
{ ... thread_data_array[t].thread_id = t; thread_data_array[t].sum = sum; thread_data_array[t].message = messages[t]; rc = pthread_create(&threads[t], NULL, PrintHello,(void *) &thread_data_array[t]); ... 
} 

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
//例子演示了错误地传递参数。循环会在线程访问传递的参数前改变传递给线程的地址的内容。 

int rc, t; 
for(t=0; t<NUM_THREADS; t++)  
{ printf("Creating thread %d\n", t); rc = pthread_create(&threads[t], NULL, PrintHello,(void *) &t); ... 
} 

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

获取当前线程id

#include<pthread.h>

pthread_t pthread_self(void);

  
 
  • 1
  • 2
  • 3

线程id的类型是pthread_t,它在当前进程中是唯一的,但是在不同系统中这个类型有不同的实现,它可能是一个整数值,也可能是一个结构体,反正就是你猜不到的东西。


判断俩线程是否相等

#include<pthread.h>

int pthread_self(pthread_t t1,pthread_t t2);

  
 
  • 1
  • 2
  • 3

注意这两个函数中的线程ID对象是不透明的,不是轻易能检查的。因为线程ID是不透明的对象,所以C语言的==操作符不能用于比较两个线程ID。


连接(Joining)和分离(Detaching)线程

pthread_join(threadid,status) pthread_detach(threadid,status) pthread_attr_setdetachstate(attr,detachstate) pthread_attr_getdetachstate(attr,detachstate)  

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
pthread_join()函数阻塞调用线程直到threadid所指定的线程终止。  
如果在目标线程中调用pthread_exit(),程序员可以在主线程中获得目标线程的终止状态。 连接线程只能用pthread_join()连接一次。若多次调用就会发生逻辑错误。 两种同步方法,互斥量(mutexes)和条件变量(condition variables),稍后讨论。

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

可连接(Joinable or Not)?

当一个线程被创建,它有一个属性定义了它是可连接的(joinable)还是分离的(detached)。
只有是可连接的线程才能被连接(joined),若果创建的线程是分离的,则不能连接。 
POSIX标准的最终草案指定了线程必须创建成可连接的。然而,并非所有实现都遵循此约定。 
使用pthread_create()的attr参数可以显式的创建可连接或分离的线程

  
 
  • 1
  • 2
  • 3
  • 4

典型四步如下:

声明一个pthread_attr_t数据类型的线程属性变量 

用 pthread_attr_init()初始化改属性变量 

用pthread_attr_setdetachstate()设置可分离状态属性 

完了后,用pthread_attr_destroy()释放属性所占用的库资源 

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

分离(Detaching):

pthread_detach()可以显式用于分离线程,尽管创建时是可连接的。
没有与pthread_detach()功能相反的函数


又到了演示线程安全的时间了

//这个例子演示了用Pthread join函数去等待线程终止。
//因为有些实现并不是默认创建线程是可连接状态,例子中显式地将其创建为可连接的。

#include <pthread.h> 
#include <stdio.h> 
#define NUM_THREADS 3 

void *BusyWork(void *null) 
{ int i; double result=0.0; for (i=0; i<1000000; i++) { result = result + (double)random(); } printf("result = %e\n",result); pthread_exit((void *) 0); 
} 

int main (int argc, char *argv[]) 
{ pthread_t thread[NUM_THREADS]; pthread_attr_t attr; int rc, t; void *status; /* Initialize and set thread detached attribute */ pthread_attr_init(&attr); pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE); for(t=0; t<NUM_THREADS; t++) { printf("Creating thread %d\n", t); rc = pthread_create(&thread[t], &attr, BusyWork, NULL); if (rc) { printf("ERROR; return code from pthread_create() is %d/n", rc); exit(-1); } } /* Free attribute and wait for the other threads */ pthread_attr_destroy(&attr); for(t=0; t<NUM_THREADS; t++) { rc = pthread_join(thread[t], &status); if (rc) { printf("ERROR; return code from pthread_join() is %d\n", rc); exit(-1); } printf("Completed join with thread %d status= %ld\n",t, (long)status); } pthread_exit(NULL); 
} 

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60

当一个线程被设置为分离线程时,如果线程的运行非常快,可能在pthread_create()函数返回之前就终止了。由于一个线程在终止以后可以将线程号和系统资源移交给其他的线程使用,此时再使用函数pthread_cretae()获得的线程号进行操作将会发生错误。


线程属性

linux下线程属性是可以根据实际项目需要进行设置。
之前我们讨论的都是线程的默认属性,默认属性已经可以解决大部分线程开发时的需求。
如果需要更高的性能,就需要人为对线程属性进行配置。

 typedef struct
{ int detachstate; //线程的分离状态 int schedpolicy; //线程的调度策略 struct sched schedparam;//线程的调度参数 int inheritsched; //线程的继承性 int scope; //线程的作用域 size_t guardsize; //线程栈末尾的警戒缓冲区大小 int stackaddr_set; //线程栈的设置 void* stackaddr; //线程栈的启始位置 size_t stacksize; //线程栈大小
}pthread_attr_t;

//在上面我们可以看到,关于这个结构体中的相关参数

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

默认的属性为非绑定、非分离、缺省的堆栈、与父进程同样级别的优先级。

线程属性设置的一般套路:

 第一:定义属性变量并初始化  pthread_attr_t pthread_attr_init()
 第二:调用你想设置的属性的接口函数 pthread_attr_setxxxxxxxx()
 第三:创建线程的时候,第二个参数使用这个属性 第四:销毁属性 pthread_destroy();

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8


互斥量

互斥量存在的意义

做个小实验吧,两个线程计数。如果最后加起来是20万那就不用往下看了。

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

int count = 0;//声明全局变量,等下就看看它了

void *run(void *arg)
{
	int i = 0;
	for(i = 0;i < 100000; i++)
	{
		count++;
		printf("Count:%d\n",count);
		usleep(2);
	}
	return (void*)0;
}

int main(int argc,char **argv)
{
	pthread_t tid1,tid2;
	int err1,err2;

	err1 = pthread_create(&tid1,NULL,run,NULL);
	err2 = pthread_create(&tid2,NULL,run,NULL);

	if(err1==0 && err2==0)//俩线程都成功创建出来
	{
		pthread_join(tid1,NULL);
		pthread_join(tid2,NULL);
	}
	return 0;
}

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33

好,为什么要线程同步,那就心照不宣了

算了,官方话还是要说一说的

1、共享资源,多个线程都可以对共享资源进行操作
2、线程操作共享资源的先后顺序不一定
3、处理器对存储器的操作一般不是原子操作

互斥锁原语

pthread_mutex_t mutex = PTHREAD_MUREX_INITALIZER //用于初始化互斥锁,后面简称锁

int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr); //初始化锁,和上面那个一个意思。
//初始化一个互斥锁(互斥量)–>初值可看做1
int pthread_mutex_destroy(pthread_mutex_t *mutex); //销毁锁
int pthread_mutex_lock(pthread_mutex_t *mutex); //上锁
int pthread_mutex_unlok(pthread_mutex_t *mutex); //解锁
int pthread_mutex_trylock(pthread_mutex_t *mutex); //尝试上锁

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

参数释义

<这里只释义那个init>

参数1:传出参数,调用时应传&mutex
restrict关键字:只用于限制指针,告诉编译器,所有修改该指针指向内存中内容的操作,只能通过本指针完成。不能通过除本指针以外的其他变量或指针修改。

参数2:互斥属性。是一个传入参数,通常传NULL,选用默认属性(线程间共享).
静态初始化:如果互斥锁mutex是静态分配的(定义在全局,或加了static关键字修饰),可以直接使用宏进行初始化。pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
动态初始化:局部变量应采用动态初始化。pthread_mutex_init(&mutex, NULL);

attr对象用于设置互斥量对象的属性,使用时必须声明为pthread_mutextattr_t类型,默认值可以是NULL。Pthreads标准定义了三种可选的互斥量属性:
协议(Protocol): 指定了协议用于阻止互斥量的优先级改变
优先级上限(Prioceiling):指定互斥量的优先级上限
进程共享(Process-shared):指定进程共享互斥量

注意所有实现都提供了这三个可选的互斥量属性。

Q:有多个线程等待同一个锁定的互斥量,当互斥量被解锁后,那个线程会第一个锁定互斥量? 

A:除非线程使用了优先级调度机制,否则,线程会被系统调度器去分配,那个线程会第一个锁定互斥量是随机的。 

  
 
  • 1
  • 2
  • 3

互斥量使用

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

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

int count = 0;
void *run(void *arg)
{
	int i = 0;
	for(i = 0;i < 100000; i++)
	{ pthread_mutex_lock(&mutex);
		count++;
		pthread_mutex_unlock(&mutex);
		printf("Count:%d\n",count);
		usleep(2);
	}
	return (void*)0;
}

int main(int argc,char **argv)
{
	pthread_t tid1,tid2;
	int err1,err2;
	err1 = pthread_create(&tid1,NULL,run,NULL);
	err2 = pthread_create(&tid2,NULL,run,NULL);
	if(err1==0 && err2==0)
	{
		pthread_join(tid1,NULL);
		pthread_join(tid2,NULL);
	}
	return 0;
}

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34

拿去执行,如果不是20万也可以不用往下看了。


死锁

(上一篇 进程·全家桶 在这个问题上花了不少篇幅)

为什么我要强调上锁和解锁一定要放在一起写,就是防止出现人为失误导致死锁
死锁嘛,解不开了。
要么是你忘了解开,别人也就没得用了
要么就是几个线程互相掐着关键数据导致谁也没办法完成任务,结果谁也没办法解锁。
这种情况下只有销毁掉代价最小的那个锁,让任务执行下去,不过后面要记得把那个被销毁的任务重新运作。


锁种

乐观锁

在这里插入图片描述

乐观锁,你看它名字就知道,把事情想得很单纯,它总认为资源和数据不会被别人所修改,所以读取不会上锁,但是乐观锁在进行写入操作的时候会判断当前数据是否被修改过。可以使用版本号等机制。

乐观锁多适用于多度的应用类型,这样可以提高吞吐量。

使用自增长的整数表示数据版本号:
在这里插入图片描述
若这双写互不干扰,男的取出,版本号为0,男的写入,版本号+1;女的取出,版本号为1,女的写入,版本号为2。
若这双写相互干扰了,男的取出,版本号为0;男的还没写入,女的取出,版本号为0;男的写入,版本号为1;女的写入,发现版本号不匹配,则写入失败,应该重新读取金额数和版本号。

此外,也可以通过时间戳来实现


悲观锁

在这里插入图片描述

悲观锁是一种悲观思想,它总认为最坏的情况可能会出现,它认为数据很可能会被其他人所修改,所以悲观锁在持有数据的时候总会把资源 或者 数据 锁住,这样其他线程想要请求这个资源的时候就会阻塞,直到等到悲观锁把资源释放为止。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。悲观锁的实现往往依靠数据库本身的锁功能实现。

实现有数据库的锁之类的。


乐观锁 VS 悲观锁

只能说,各有千秋吧。

乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。

悲观锁会造成访问数据库时间较长,并发性不好,特别是长事务。
乐观锁在现实中使用得较多。


自旋锁 && 互斥锁

自旋锁和互斥锁嘛,一直在用的,不过以前只是简单的叫它们:锁。原来人家有名字的啊。

wait() 晓得不?timewait()晓得不?

互斥锁:阻塞等待
自旋锁:等两下就去问一声:好了不?我很急啊!好了不?你快点啊。。。哈哈哈哈哈

自旋锁的原理比较简单,如果持有锁的线程能在短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,它们只需要等一等(自旋),等到持有锁的线程释放锁之后即可获取,这样就避免了用户进程和内核切换的消耗。

因为自旋锁避免了操作系统进程调度和线程切换,所以自旋锁通常适用在时间比较短的情况下。由于这个原因,操作系统的内核经常使用自旋锁。但是,如果长时间上锁的话,自旋锁会非常耗费性能,它阻止了其他线程的运行和调度。线程持有锁的时间越长,则持有该锁的线程将被 OS(Operating System) 调度程序中断的风险越大。如果发生中断情况,那么其他线程将保持旋转状态(反复尝试获取锁),而持有该锁的线程并不打算释放锁,这样导致的是结果是无限期推迟,直到持有锁的线程可以完成并释放它为止。

解决上面这种情况一个很好的方式是给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁。适应性自旋锁意味着自旋时间不是固定的了,而是由前一次在同一个锁上的自旋时间以及锁拥有的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。

条件变量

  • 条件变量提供了另一种同步的方式。互斥量通过控制对数据的访问实现了同步,而条件变量允许根据实际的数据值来实现同步。
  • 没有条件变量,程序员就必须使用线程去轮询(可能在临界区),查看条件是否满足。这样比较消耗资源,因为线程连续繁忙工作。条件变量是一种可以实现这种轮询的方式。
  • 条件变量往往和互斥一起使用

使用条件变量的代表性顺序如下:
在这里插入图片描述

条件变量原语

//初始化条件变量:
//本人还是喜欢静态初始化,省事儿
pthread_cont_t cont = PTHREAD_COND_INITIALIZER;

//好,再看看动态初始化
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
//参数释义:cond:用于接收初始化成功管道条件变量
//attr:通常为NULL,且被忽略

//有初始化那肯定得有销毁
int pthread_cond_destroy(pthread_cond_t *cond);

//既然说条件变量是用来等待的,那就更要看看这等待的特殊之处了
int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex);  //无条件等待

int pthread_cond_timedwait(pthread_cond_t *cond,pthread_mutex_t mytex,const struct timespec *abstime);  //计时等待

//好,加入等待唤醒大军了,那得看看怎么去唤醒了
int pthread_cond_signal(pthread_cond_t *cptr); //唤醒一个等待该条件的线程。存在多个线程是按照其队列入队顺序唤醒其中一个
int pthread_cond_broadcast(pthread_cond_t * cptr); //广播,唤醒所哟与等待线程


  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

条件变量与互斥锁

在服务器编程中常用的线程池,多个线程会操作同一个任务队列,一旦发现任务队列中有新的任务,子线程将取出任务;这里因为是多线程操作,必然会涉及到用互斥锁保护任务队列的情况(否则其中一个线程操作了任务队列,取出线程到一半时,线程切换又取出相同任务)。但是互斥锁一个明显的缺点是它只有两种状态:锁定和非锁定。设想,每个线程为了获取新的任务不断得进行这样的操作:锁定任务队列,检查任务队列是否有新的任务,取得新的任务(有新的任务)或不做任何操作(无新的任务),释放锁,这将是很消耗资源的。

而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,它常和互斥锁一起配合使用。使用时,条件变量被用来阻塞一个线程,当条件不满足时,线程往往解开相应的互斥锁并等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。这些线程将重新锁定互斥锁并重新测试条件是否满足。一般说来,条件变量被用来进行线程间的同步。对应于线程池的场景,我们可以让线程处于等待状态,当主线程将新的任务放入工作队列时,发出通知(其中一个或多个),得到通知的线程重新获得锁,取得任务,执行相关操作。

注意事项

(1)必须在互斥锁的保护下唤醒,否则唤醒可能发生在锁定条件变量之前,照成死锁。
(2)唤醒阻塞在条件变量上的所有线程的顺序由调度策略决定
(3)如果没有线程被阻塞在调度队列上,那么唤醒将没有作用。
(4)以前不懂事儿,就喜欢广播。由于pthread_cond_broadcast函数唤醒所有阻塞在某个条件变量上的线程,这些线程被唤醒后将再次竞争相应的互斥锁,所以必须小心使用pthread_cond_broadcast函数。

虚假唤醒与唤醒丢失

⑴虚假唤醒

在多核处理器下,pthread_cond_signal可能会激活多于一个线程(阻塞在条件变量上的线程)。结果是,当一个线程调用pthread_cond_signal()后,多个调用pthread_cond_wait()或pthread_cond_timedwait()的线程返回。这种效应成为”虚假唤醒”(spurious wakeup)

Linux帮助里面有
为什么不去修正,性价比不高嘛。

所以通常的标准解决办法是这样的:

在这里插入图片描述

⑵唤醒丢失

无论哪种等待方式,都必须和一个互斥量配合,以防止多个线程来打扰。
互斥锁必须是普通锁或适应锁,并且在进入pthread_cond_wait之前必须由本线程加锁。
在更新等待队列前,mutex必须保持锁定状态. 在线程进入挂起,进入等待前,解锁。(好绕啊,我已经尽力断句了)
在条件满足并离开pthread_cond_wait前,上锁。以恢复它进入cont_wait之前的状态。

为什么等待会被上锁?

以免出现唤醒丢失问题。
这里有个大神解释要不要看:https://stackoverflow.com/questions/4544234/calling-pthread-cond-signal-without-locking-mutex
做事做全套,源码也给放这儿了:https://code.woboq.org/userspace/glibc/nptl/pthread_cond_wait.c.html

  
 
  • 1
  • 2
  • 3

在放些咱能看懂的中文解释:将线程加入唤醒队列后方可解锁。保证了线程在陷入wait后至被加入唤醒队列这段时间内是原子的。
但这种原子性依赖一个前提条件:唤醒者在调用pthread_cond_broadcast或pthread_cond_signal唤醒等待者之前也必须对相同的mutex加锁。
满足上述条件后,如果一个等待事件A发生在唤醒事件B之前,那么A也同样在B之前获得了mutex,那A在被加入唤醒队列之前B都无法进入唤醒调用,因此保证了B一定能够唤醒A;试想,如果A、B之间没有mutex来同步,虽然B在A之后发生,但是可能B唤醒时A尚未被加入到唤醒队列,这便是所谓的唤醒丢失。


在线程未获得相应的互斥锁时调用pthread_cond_signal或pthread_cond_broadcast函数可能会引起唤醒丢失问题。

唤醒丢失往往会在下面的情况下发生:

一个线程调用pthread_cond_signal或pthread_cond_broadcast函数;
另一个线程正处在测试条件变量和调用pthread_cond_wait函数之间;
没有线程正在处在阻塞等待的状态下。

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

使用条件变量

//例子演示了使用Pthreads条件变量的几个函数。主程序创建了三个线程,两个线程工作,根系“count”变量。第三个线程等待count变量值达到指定的值。 

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

#define NUM_THREADS  3 
#define TCOUNT 10 
#define COUNT_LIMIT 12 

int count = 0; 
int thread_ids[3] = {0,1,2}; 
pthread_mutex_t count_mutex; 
pthread_cond_t count_threshold_cv; 

void *inc_count(void *idp)  
{ int j,i; double result=0.0; int *my_id = idp; for(i=0; i<TCOUNT; i++) { pthread_mutex_lock(&count_mutex); count++; /* Check the value of count and signal waiting thread when condition is reached.  Note that this occurs while mutex is locked. */ if (count == COUNT_LIMIT) { pthread_cond_signal(&count_threshold_cv); printf("inc_count(): thread %d, count = %d  Threshold reached./n",*my_id, count); } printf("inc_count(): thread %d, count = %d, unlocking mutex/n",*my_id, count); pthread_mutex_unlock(&count_mutex); /* Do some work so threads can alternate on mutex lock */ for (j=0; j<1000; j++) result = result + (double)random(); } pthread_exit(NULL); 
} 

void *watch_count(void *idp)  
{ int *my_id = idp; printf("Starting watch_count(): thread %d/n", *my_id); /* Lock mutex and wait for signal.  Note that the pthread_cond_wait routine will automatically and atomically unlock mutex while it waits. Also, note that if COUNT_LIMIT is reached before this routine is run by the waiting thread, the loop will be skipped to prevent pthread_cond_wait \
  from never returning. */ pthread_mutex_lock(&count_mutex); if (count<COUNT_LIMIT) { pthread_cond_wait(&count_threshold_cv, &count_mutex); printf("watch_count(): thread %d Condition signal received./n", *my_id); } pthread_mutex_unlock(&count_mutex); pthread_exit(NULL); 
} 

int main(int argc, char *argv[]) 
{ int i, rc; pthread_t threads[3]; pthread_attr_t attr; /* Initialize mutex and condition variable objects */ pthread_mutex_init(&count_mutex, NULL); pthread_cond_init (&count_threshold_cv, NULL); /* For portability, explicitly create threads in a joinable state */ pthread_attr_init(&attr); pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE); pthread_create(&threads[0], &attr, inc_count, (void *)&thread_ids[0]); pthread_create(&threads[1], &attr, inc_count, (void *)&thread_ids[1]); pthread_create(&threads[2], &attr, watch_count, (void *)&thread_ids[2]); /* Wait for all threads to complete */ for (i=0; i<NUM_THREADS; i++) { pthread_join(threads[i], NULL); } printf ("Main(): Waited on %d  threads. Done./n", NUM_THREADS); /* Clean up and exit */ pthread_attr_destroy(&attr); pthread_mutex_destroy(&count_mutex); pthread_cond_destroy(&count_threshold_cv); pthread_exit(NULL); 
} 

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91

线程池

线程池

番外篇

Pthread API函数

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


多线程下的对象创建

对象构造要做到线程安全,就一点要求:不要暴露自己,即不要泄露this指针。
那就是做到以下几点:

不要在构造函数中注册任何回调
不要在构造函数中将this传给跨线程对象
即时在构造函数最后一行也不行

  
 
  • 1
  • 2
  • 3

对于第一点,如果非要回调函数才能构造,那就换二段式构造,先构造,在调用回调函数。
对于第三条,如果这个类是个基类呢?它构造完了并不是真的构造完了,还有子类等着呢。

之所以要这样设计(把this传给子类那另当别论),就是为了防止构造过程被打断,构造出一个半成品。

对象的销毁与竞态条件

对象析构,在多线程里,由于竞态的存在,变得扑朔迷离。
看个例子:

Foo::~Foo(){
	//拿锁
	//析构
	//解锁
}
void Foo::update(){
	//拿锁
	//数据操作
	//解锁
}

extern Foo *f;//共享资源

A进程操作
delete f;
f = NULL;

B进程操作
if(f)
{
	f->update();
}

  
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

那这就有一个很尴尬的情况了:
A在执行“析构”的时候,已经拿到了锁,而B通过了 f 的判断,因为那会儿指针还活着,然后被锁卡住了。
接下来会发生什么?不知道,因为对象析构的时候把锁也带走了。。。(锁属于对象,对象析构,锁也跑不了)

那怎么办?
别怕,参考博客:智能指针

一个动态创建的对象,是否还有效光看指针是看不出来的指针就是指向了一块内存而已,这块内存上的对象如果已经被销毁,那就根本不能访问。

shared_ptr/weak_ptr

shared_ptr是引用计数型智能指针,被纳入C11标准库。shared_ptr是一个类模板,它只有一个参数,使用起来很方便。

shared_str是强引用,只要有一个指向x对象的shared_ptr存在,该对象及不会被析构。
weak_ptr是弱引用,它不控制对象的生命周期,但是它知道对象是否还存在。如果对象存在,它可以升级成为shared_ptr。

讲这么多不如来个例子实在:

class Observer{
private:
	std::vector<weak_ptr<Observer>> vwo;	//像这样用啊
}

  
 
  • 1
  • 2
  • 3
  • 4

再聊会儿C++内存安全

C++里面可能出现的内存问题大致有这么几个方面

  • 缓冲区溢出
  • 空悬指针/野指针
  • 重复释放
  • 内存泄漏
  • 不配对的new[]/delete
  • 内存碎片

对应解决:

  • std::vetor
  • shared_ptr/weak_ptr
  • scoped_ptr,只在对象析构的时候释放一次
  • scoped_ptr
  • std::vetor

资源推荐

Programing with POSIX thread(POSIX多线程程序设计)
需要私信我。

在这里插入图片描述

文章来源: lion-wu.blog.csdn.net,作者:看,未来,版权归原作者所有,如需转载,请联系作者。

原文链接:lion-wu.blog.csdn.net/article/details/113679560

【版权声明】本文为华为云社区用户转载文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。