进程是资源分配的最小单位,线程是程序执行的最小单位…
为什么使用线程
- 多线程之间会共享同一块地址空间和所有可用数据的能力,这是进程所不具备的
- 线程要比进程更轻量级 ,由于线程更轻,所以它比进程(fork创建进程以执行新的任务,该方式的代价很高)更容易创建,也更容易撤销。在许多系统中,创建一个线程要比创建一个进程快 10-100 倍。
- 第三个原因可能是性能方面的探讨,如果多个线程都是 CPU 密集型的,那么并不能获得性能上的增强,但是如果存在着大量的计算和大量的 I/O 处理,拥有多个线程能在这些活动中彼此重叠进行,从而会加快应用程序的执行速度。
- 线程是进程的基本执行单元,一个进程的所有任务都在线程中执行,进程要想执行任务,必须得有线程,进程至少要有一条线程,程序启动会默认开启一条线程,这条线程被称为主线程或 UI 线程
什么是线程
线程,是进程内部的一个控制序列。
即使不使用线程,进程内部也有一个执行线程。
进程中拥有一个执行的线程,通常简写为 线程(thread)。
线程会有程序计数器,用来记录接着要执行哪一条指令;线程还拥有寄存器,用来保存线程当前正在使用的变量;线程还会有堆栈,用来记录程序的执行路径。
尽管线程必须在某个进程中执行,但是进程和线程完完全全是两个不同的概念,并且他们可以分开处理。进程用于把资源集中在一起,而线程则是 CPU 上调度执行的实体。
线程不像是进程那样具备较强的独立性。同一个进程中的所有线程都会有完全一样的地址空间,这意味着它们也共享同样的全局变量。
注意:单核处理器上,同一个时刻只能运行一个线程。但是对于用户而言,感觉如同同时执行了多个线程一样(各线程在单核CPU上切换,在一段时间内,同时执行了多个线程)
线程的优点、缺点
优点: 创建线程比创建进程,开销要小。
缺点:
1)多线程编程,需特别小心,很容易发生错误。
2)多线程调试很困难。
3)把一个任务划分为两部分,用两个线程在单处理器上运行时,不一定更快。除非能确定这两个部分能同时执行、且运行在多处理器上。
线程的应用场合
-
需要让用户感觉在同时做多件事情时,比如,处理文档的进程,一个线程处理用户编辑,一个线程同时统计用户的字数。
-
当一个应用程序,需要同时处理输入、计算、输出时,可开3个线程,分别处理输入、计算、输出。让用户感觉不到等待。
-
高并发编程。
线程实现
主要有三种实现方式
- 在用户空间中实现线程;
- 在内核空间中实现线程;
- 在用户和内核空间中混合实现线程。
在用户空间中实现线程
第一种方法是把整个线程包放在用户空间中,内核对线程一无所知,它不知道线程的存在。所有的这类实现都有同样的通用结构
线程在运行时系统之上运行,运行时系统是管理线程过程的集合,包括四个过程:pthread create
,pthread exit
, pthread join
和 pthread yield
.
运行时系统(Runtime System) 也叫做运行时环境,该运行时系统提供了程序在其中运行的环境。此环境可能会解决许多问题,包括应用程序内存的布局,程序如何访问变量,在过程之间传递参数的机制,与操作系统的接口等等。编译器根据特定的运行时系统进行假设以生成正确的代码。通常,运行时系统将负责设置和管理堆栈,并且会包含诸如垃圾收集,线程或语言内置的其他动态的功能。
在用户空间管理线程时,每个进程需要有其专用的 线程表(thread table),用来跟踪该进程中的线程。这些表和内核中的进程表类似,不过它仅仅记录各个线程的属性,如每个线程的程序计数器、堆栈指针、寄存器和状态。该线程标由运行时系统统一管理。当一个线程转换到就绪状态或阻塞状态时,在该线程表中存放重新启动该线程的所有信息,与内核在进程表中存放的信息完全一样。
在用户空间实现线程的优势:
(1)在用户空间中实现线程要比在内核空间中实现线程具有这些方面的优势:考虑如果在线程完成时或者是在调用 pthread_yield
时,必要时会进程线程切换,然后线程的信息会被保存在运行时环境所提供的线程表中,然后,线程调度程序来选择另外一个需要运行的线程。保存线程的状态和调度程序都是 本地过程 ,所以启动他们比进行内核调用效率更高。因而不需要切换到内核,也就不需要上下文切换,也不需要对内存高速缓存进行刷新,因为线程调度非常便捷,因此效率比较高。
(2)在用户空间实现线程还有一个优势就是它允许每个进程有自己定制的调度算法。例如在某些应用程序中,那些具有垃圾收集线程的应用程序(知道是谁了吧)就不用担心自己线程会不会在不合适的时候停止,这是一个优势。用户线程还具有较好的可扩展性,因为内核空间中的内核线程需要一些表空间和堆栈空间,如果内核线程数量比较大,容易造成问题。
在用户空间实现线程的劣势:
(1)使用线程的一个目标是能够让线程进行阻塞调用,并且要避免被阻塞的线程影响其他线程。
(2)如果只有一个线程引起页面故障,内核由于甚至不知道有线程存在,通常会把整个进程阻塞直到磁盘 I/O 完成为止,尽管其他的线程是可以运行的。
(3)在一个单进程内部,没有时钟中断,不可能使用轮转调度的方式调度线程。
计算机并不会把所有的程序都一次性的放入内存中,如果某个程序发生函数调用或者跳转指令到了一条不在内存的指令上,就会发生页面故障,而操作系统将到磁盘上取回这个丢失的指令,这就称为 缺页故障
在内核中实现线程
如果使用内核来实现线程,此时就不再需要运行时环境了。另外,每个进程中也没有线程表。相反,在内核中会有用来记录系统中所有线程的线程表。当某个线程希望创建一个新线程或撤销一个已有线程时,它会进行一个系统调用,这个系统调用通过对线程表的更新来完成线程创建或销毁工作。
内核中的线程表持有每个线程的寄存器、状态和其他信息。这些信息和用户空间中的线程信息相同,但是位置却被放在了内核中而不是用户空间中。另外,内核还维护了一张进程表用来跟踪系统状态。
所有能够阻塞的调用都会通过系统调用的方式来实现,当一个线程阻塞时,内核可以进行选择,是运行在同一个进程中的另一个线程(如果有就绪线程的话)还是运行一个另一个进程中的线程。但是在用户实现中,运行时系统始终运行自己的线程,直到内核剥夺它的 CPU 时间片(或者没有可运行的线程存在了)为止。
由于在内核中创建或者销毁线程的开销比较大,所以某些系统会采用可循环利用的方式来回收线程。当某个线程被销毁时,就把它标志为不可运行的状态,但是其内部结构没有受到影响。稍后,在必须创建一个新线程时,就会重新启用旧线程,把它标志为可用状态。
如果某个进程中的线程造成缺页故障后,内核很容易的就能检查出来是否有其他可运行的线程,如果有的话,在等待所需要的页面从磁盘读入时,就选择一个可运行的线程运行。这样做的缺点是系统调用的代价比较大,所以如果线程的操作(创建、终止)比较多,就会带来很大的开销。
混合实现
内核级线程的方式,令然后将用户级线程与某些或者全部内核线程多路复用起来。
在这种模型中,编程人员可以自由控制用户线程和内核线程的数量,具有很大的灵活度。采用这种方法,内核只识别内核级线程,并对其进行调度。其中一些内核级线程会被多个用户级线程多路复用。
线程的使用
pthread_create
主要作用是在一个多线程的程序中创建一个新的线程,该线程将在指定的函数中开始执行。
int pthread_create(
pthread_t *thread, // 用于存储新线程的标识符
const pthread_attr_t *attr, // 线程的属性,通常为 NULL,表示使用默认属性
void *(*start_routine)(void *), // 新线程将执行的函数指针
void *arg // 传递给新线程函数的参数
);
参数说明:
thread
: 一个指向 pthread_t
类型的指针,用于存储新线程的标识符。通过这个标识符,你可以对新线程进行操作,如等待它的完成或取消它。
attr
: 一个指向 pthread_attr_t
类型的指针,表示线程的属性。通常情况下,可以将其设置为 NULL
,表示使用默认属性。如果需要设置线程的特殊属性,可以创建一个 pthread_attr_t
对象并配置相应的属性,然后将其传递给 pthread_create
。
start_routine
: 一个函数指针,指向新线程将要执行的函数。这个函数必须接受一个 void*
类型的参数,并返回一个 void*
类型的指针。新线程将从这个函数开始执行。
arg
: 一个 void*
类型的参数,用于传递给 start_routine
函数。这是一个指向任何类型的指针,允许你向新线程传递任何需要的数据。
pthread_create
函数成功创建新线程时,会将新线程的标识符存储在 thread
指针所指向的位置,并开始执行 start_routine
函数,同时将 arg
参数传递给 start_routine
。
pthread_exit
pthread_exit()
函数只适用于线程函数,而不能用于普通函数。
void pthread_exit(void *retval);
retval
是void*
类型的指针,可以指向任何类型的数据,它指向的数据将作为线程退出时的返回值。如果线程不需要返回任何数据,将 retval
参数置为NULL
即可。
注意,retval 指针不能指向函数内部的局部数据(比如局部变量)。换句话说,pthread_exit() 函数不能返回一个指向局部数据的指针,否则很可能使程序运行结果出错甚至崩溃。
pthread_join
在很多情况下,主线程生成并起动了子线程,如果子线程里要进行大量的耗时的运算,主线程往往将于子线程之前结束,但是如果主线程处理完其他的事务后,需要用到子线程的处理结果,也就是主线程需要等待子线程执行完成之后再结束,这个时候就要用到pthread_join()方法了。
主线程等待子线程的终止。也就是在主线程调用了pthread_join()
方法后面的代码,只有等到子线程结束了才能执行
int pthread_join(pthread_t thread, void **retval);
args:
pthread_t thread //被连接线程的线程号
void **retval //指向一个指向被连接线程的返回码的指针的指针
return:
//线程连接的状态,0是成功,非0是失败
pthread_join()
有两种作用:
1-用于等待其他线程结束:当调用 pthread_join()
时,当前线程会处于阻塞状态,直到被调用的线程结束后,当前线程才会重新开始执行。
2-对线程的资源进行回收:如果一个线程是非分离的(默认情况下创建的线程都是非分离)并且没有对该线程使用 pthread_join()
的话,该线程结束后并不会释放其内存空间,这会导致该线程变成了“僵尸线程”。
使用线程程序的编译
(1) 编译时,定义宏_REENTRANT
即: gcc -D_REENTRANT
(#define REENTRANT
)
功能:告诉编译器,编译时需要可重入功能。
即使得,在编译时,编译部分函数的可重入版本。
单线程程序中,整个程序都是顺序执行的,一个函数在同一时刻只能被一个函数调用,但在多线程中,由于并发性,一个函数可能同时被多个函数调用,此时这个函数就成了临界资源,很容易造成调用函数处理结果的相互影响,如果一个函数在多线程并发的环境中每次被调用产生的结果是不确定的,我们就说这个函数是"不可重入的"/"线程不安全"的。
(2) 编译时,指定线程库
即: gcc -lpthread
功能:使用系统默认的NPTL
线程库,即在默认路径中寻找库文件libpthread.so
,默认路径为/usr/lib
和/usr/local/lib
。
当系统默认使用的不是NPTL线程库时
指定:gcc -L/usr/lib/nptl -lpthread
补充:
-L
指定库文件所在的目录
-l
指定库文件的名称(-lpthread
,指库文件名为libpthread.so
)
总结:一般使用如下形式即可
gcc -D_REENTRANT -lpthread mythread.c -o mythread
线程的同步
线程的互斥 - 指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
线程的同步 - 指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。
同一个进程内的各个线程,共享该进程内的全局变量
如果多个线程同时对某个全局变量进行访问时,就可能导致竞态。
解决办法: 对临界区使用信号量、或互斥量。
对于同步和互斥,使用信号量或互斥量都可以实现。
使用时,选择更符合语义的手段:
1.如果要求最多只允许一个线程进入临界区,则使用互斥量;
2.如果要求多个线程之间的执行顺序满足某个约束,则使用信号量
信号量
此时所指的“信号量”是指用于同一个进程内多个线程之间的信号量。即POSIX信号量,而不是System V信号量(用于进程之间的同步)
用于线程的信号量的原理,与用于进程之间的信号量的原理相同。
都有P
操作、V
操作。
信号量的表示:sem_t
类型
信号量的初始化:sem_init
int sem_init (sem_t *sem, int pshared, unsigned int value);
参数:
sem
, 指向被初始化的信号量
pshared
, 0:表示该信号量是该进程内使用的“局部信号量”, 不再被其它进程共享。非0:该信号量可被其他进程共享,Linux不支持这种信号量
value
, 信号量的初值。>= 0
返回值:成功,返回0;失败, 返回错误码
信号量的P操作:sem_wait
int sem_wait (sem_t *sem);
返回值:成功,返回0;失败, 返回错误码
信号量的V操作:sem_post
int sem_post (sem_t *sem);
返回值:成功,返回0;失败, 返回错误码
线程的互斥量
pthread_mutex_t
是 POSIX(Portable Operating System Interface) 线程库中的数据类型,通常简称为 Pthreads
。
它提供了一种标准化的方式,用于多线程程序同步访问共享资源,以防止数据损坏和竞争条件。
pthread_mutex_t
实际上是一个 互斥锁(mutex) 对象。
它用于创建和管理多线程程序中的互斥锁。互斥锁是同步原语,允许多个线程协调工作,确保在任何时刻只有一个线程可以访问关键代码段或共享资源。这可以防止在多个线程同时尝试访问相同资源时发生冲突和数据损坏。
与 pthread_mutex_t
相关的一些关键操作和函数:
初始化:通常使用 pthread_mutex_init
函数来初始化 pthread_mutex_t
。
加锁:线程可以使用 pthread_mutex_lock
来获取互斥锁。如果互斥锁已经被另一个线程锁定,调用线程将会阻塞,直到互斥锁变为可用。
解锁:线程使用 pthread_mutex_unlock
来释放互斥锁,允许其他线程获取锁。
尝试加锁:pthread_mutex_trylock
是 pthread_mutex_lock
的非阻塞替代方法。它尝试获取互斥锁,如果互斥锁已经被另一个线程锁定,它会立即返回一个错误代码,而不会阻塞。
销毁:当您不再需要一个 pthread_mutex_t
时,应该销毁它,通常使用 pthread_mutex_destroy
函数来完成。
C++多线程之——pthread_mutex_t
互斥量初始化
int pthread_mutex_init(pthread_mutex_t *mutex, pthread_mutexattr_t *attr);
参数:
mutex
, 指向被初始化的互斥量
attr
, 指向互斥量的属性,一般取默认属性(当一个线程已获取互斥量后,该线程再次获取该信号量,将导致死锁!)
互斥量获取
int pthread_mutex_lock (pthread_mutex_t *mutex);
互斥量释放
int pthread_mutex_unlock (pthread_mutex_t *mutex);
互斥量删除
int pthread_mutex_destroy (pthread_mutex_t *mutex);
线程的条件变量
与互斥锁不同,条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。 通常条件变量和互斥锁同时使用。
条件变量使我们可以睡眠等待某种条件出现。
条件变量是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待"条件变量的条件成立"而挂起;另一个线程使"条件成立"(给出条件成立信号)。
条件的检测是在互斥锁的保护下进行的。 如果一个条件为假,一个线程自动阻塞,并释放等待状态改变的互斥锁。如果另一个线程改变了条件,它发信号给关联的条件变量,唤醒一个或多个等待它的线程,重新获得互斥锁,重新评价条件。如果两进程共享可读写的内存,条件变量可以被用来实现这两进程间的线程同步。
man 安装: apt-get install manpages-posix-dev
条件变量初始化:int pthread_cond_init (pthread_cond_t *cond, const pthread_condattr_t *attr);
参数:
cond
, 条件变量指针
attr
, 条件变量高级属性
唤醒一个等待线程: int pthread_cond_signal (pthread_cond_t *cond);
通知条件变量,唤醒一个等待者
参数:cond, 条件变量指针
唤醒所有等待该条件变量的线程:int pthread_cond_broadcast (pthread_cond_t *cond);
广播条件变量
参数:cond
, 条件变量指针
等待条件变量/超时被唤醒:int pthread_cond_timedwait (pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime);
等待条件变量cond
被唤醒,直到由一个信号或广播,或绝对时间abstime
到 *
才唤醒该线程
参数:
cond
, 条件变量指针
pthread_mutex_t *mutex
互斥量
const struct timespec *abstime
等待被唤醒的绝对超时时间
等待条件变量被唤醒:int pthread_cond_wait (pthread_cond_t *cond, pthread_mutex_t *mutex);
等待条件变量cond
被唤醒(由一个信号或者广播)
参数:
cond
, 条件变量指针
pthread_mutex_t *mutex
互斥量
常见错误码:
[EINVAL] cond或mutex无效,
[EINVAL] 同时等待不同的互斥量
[EINVAL] 主调线程没有占有互斥量
释放/销毁条件变量:int pthread_cond_destroy (pthread_cond_t *cond);
待销毁的条件变量
参数:cond
, 条件变量指针