并发编程

2022/2/13 17:14:43

本文主要是介绍并发编程,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

一、进程与线程

在计算机上运行的程序是指令与数据的组合,指令按照既定的逻辑控制计算机运行。操作系统以进程为单位分配资源,线程是操作系统调度执行的最小单位。

1.进程与线程的区别

1.进程有自己的地址空间,多个线程共用一个地址空间:

——线程可以节省系统资源,不仅可以保持效率,甚至能够提高效率。

——每个线程拥有自己独立的栈区和寄存器。

——多个线程共享代码段,堆区,全局数据区,文件描述符表。

 

2.线程是程序的最小执行单位,进程是操作系统中最小的资源分配单位

——一个进程对应一个虚拟地址空间。

——多个线程可以共享一个虚拟地址空间。

 

3.CPU的调度和切换

线程的上下文切换比进程快得多。

上下文切换:进程 / 线程分时复用 CPU 时间片,在切换之前会将上一个任务的状态进行保存,下次切换回这个任务的时候,加载这个状态继续运行,任务从保存到再次加载这个过程就是一次上下文切换。

 

4.线程启动更快,退出更快。

在处理多任务程序的时候,使用多线程比多进程更有优势,但线程并不是越多越好,如何控制线程个数呢?

 

二、线程创建与退出

1.创建线程

1.线程函数

1.获取线程 ID:

每个线程都有一个唯一的 ID,如果想要获取这个 ID,可以用如下函数:

pthread_t pthread_self(void); // 返回当前线程的线程ID

2.创建线程

用以下的线程函数创建子线程:

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

参数:

——thread,线程创建成功后,将线程 ID 写入该指针指向的内存

——attr,线程的属性,如果没有特殊要求,可以填NULL

——start_routine,函数指针,指向想要线程执行的函数

——arg,执行函数的输入参数,可以填NULL

2.编译须知

在使用 gcc 编译时,需用参数 -lpthread 指定线程动态库:

gcc *.c -lpthread

3.主线程与子线程

如果主线程执行完退出了,虚拟地址空间就会被释放,子线程也跟着被销毁了,反过来子线程退出不影响主线程。可以调用线程退出函数避免这种情况。

2.线程退出

在编写多线程程序的时候,如果想要让线程退出,但是不会导致虚拟地址空间的释放(针对于主线程),可以调用线程退出函数退出线程,不会影响到其他线程的正常运行,不管是在子线程或者主线程中都可以使用。

void pthread_exit(void *retval);

参数:

——retval,线程退出时携带的数据,当前子线程的主线程会得到该数据,如果不需要,指定为 NULL。

3.线程回收

—自动回收线程 。

pthread_detach(pid)

—阻塞函数,等待线程运行结束后回收资源。

pthread_join(pid, reval) 

4.其他线程函数

1.线程取消

杀死一个线程分两步:

首先,调用线程取消函数 pthread_cancel 。

接下来,在想要杀死的线程中调用系统函数。(从用户区切换到内核区)

int pthread_cancel(pthread_t thread);

参数:

——线程 ID ,指定要杀死的线程。

——返回值,函数执行成功返回 0 ,否则返回其他错误码。

2.线程 ID 比较

在某些平台上,pthread_t 不是一个单纯的整型,需要用比较函数进行比较。

int pthread_equal(pthread_t t1, pthread_t t2);

参数:

——t1 和 t2 是想要比较的线程 ID

——返回值,不相等返回 0。

 

三、线程常见问题

1.数据竞争

 在多线程中,有两个及以上的线程在同一时间对同一块内存的数据进行了非原子写操作,在这种情况下,运行结果与预想结果不符。

创建多个线程,对 count 变量进行加一,对 count 加一的汇编指令如下:

mov eax, DWORD PTR counter[rip]

add eax, 1

mov DWORD PTR counter[rip], eax

当线程 A 执行完第二条指令后,切换到线程 B 开始执行并完成了对 count 加一的操作,再切换回线程 A 完成第三条指令,此时 count 仅加了 1 ,但预期结果是加 2 。

2.竞态条件

 在多线程环境下,程序中某些事件发生的时机与顺序不一致,从而影响程序结果的一种情况。

假定有多个线程,线程函数的流程如下:

1.判断账户 A 余额是否足够

2.如果足够,将资金累加到账户 B 上

3.扣除账户 A 的资金

存在这样一种情况,如果线程 1 执行完第一个步骤后,切换到线程 2 执行完所有步骤,再切换回线程 1 ,此时账户 A 中的资金可以已经不够扣除了,但仍然能执行完第三步,导致结果变成负数。

3.指令重排

程序在真正执行时的顺序,与编译后的汇编代码不一致。

如下图所示, run 函数属于线程 1 ,observe 函数属于线程 2 :

 

 实际执行时,线程 1 可能先执行完 y = 20,再切换到线程 2 ,此时并没有给 x 赋值,打印出的 x 的值,与预期结果不符。

 

四、解决并发编程常见问题的方法

第三章中提到了一些并发编程遇到的问题,对于这些问题,本章总结了一些解决方法。

1.使用互斥量

通过加锁和解锁的方式,来限制多个线程的执行,让它们有序的使用共享资源。(数据竞争的解决办法)

int mtx_init(mtx_t * mutex,int type;

C语言中的锁可以分为三种类型:

1.mtx_plain

仅实现最基本的加锁解锁功能,不能用在需要重复加锁的场景中,因为线程必须要等待解锁后才能加锁,否则会导致死锁问题。

2.mtx_recursive

可以被同一个线程重复锁定多次,想要解锁也需要解锁同样多的次数。

3.mtx_timed

以阻塞的方式等待加锁,通过 mtx_timedlock 函数 设定超时时间,如果超时后仍未能加锁,则继续执行。

2.使用原子操作

原子操作无法被分解为更多的步骤,通过使用 _Atomic 关键字,将变量定义为原子类型。当线程访问该变量时,可以一次性完成整个操作

C 语言提供了标准库去实现原子操作,只需引用 stdatomic.h 头文件即可,想要了解更多可以参考链接。

除了最基本的加减乘除以外,还可以指定各个原子操作的内存顺序,可以解决之前提到的指令重排问题,修改后的代码如下:

 

现在,必须在 x 被赋值以后,才能对 y 赋值,用到的枚举值如下图所示,其他的枚举值可以参考链接:

 

 

3.使用条件变量

条件变量提供了线程间的通知能力,当一个线程完成后,可以通知另一个线程完成后续的工作。

上一节中, observe 的代码使用的是 “忙等待” 的方式,在等待期间对 CPU 资源的占用率很高,可以选择条件变量来解决这个问题。

cnd_wait 函数在没有收到通知时,挂起当前线程,解锁并阻塞在此处,当收到 cnd_signal 函数的通知时,唤醒该线程并加锁。

需要注意的时 cnd_wait 函数第一步先解锁,所以需要将该互斥锁进行加锁,否则结果是未定义的。(此处参考链接)

而且,有可能存在虚假唤醒,所以当 cnd_wait 函数返回后,应当再检查一次 done 的值,所以用了 while 循环。 

4.使用线程本地变量

线程有自己的本地变量 TSS,为全局变量添加 _Thread_local 关键字。这样,就会仅在线程创建时,生成仅属于当前线程的本地同名变量。

对于本地变量的操作有以下这些函数,从函数名就可以看出函数的作用:

 

 

 在线程创建时,会生成一个属于自己的线程的本地变量,可以理解为对全局变量的一个拷贝,不同的线程对这个变量进行操作时,只会操作自己的线程本地变量,不会对其他线程的本地变量造成干扰。

比如下图所示的代码:

 

 

 函数运行完成后,通过 thread_join 函数释放线程资源,并获取每个线程的返回值,相加后就是所有线程累加的和。

 



这篇关于并发编程的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程