【编程基础】进程、线程与协程

进程、线程、协程

并行与并发

并行:真正意义上的同时进行多个任务。这只能在多核 CPU 上实现。

并发:从宏观上看,并发就是同时进行多个事件。但实际上,这些事件并不是在同时进行,而是交替进行的。由于 CPU 的运算速度非常快,给我们造成了一种在同一时间内进行多个事件的错觉。

进程

进程是具有一定独立功能的程序,它是系统进行资源分配和调度的一个独立单位。在出现线程前,进程是拥有资源和独立调度的基本单位。程序运行时,系统会创建一个进程,并为它分配资源,然后把该进程放入进程就绪队列进程调度器选中它时就会为它分配 CPU 事件,程序正式开始运行。

引进进程的目的,是为了使多道程序并发执行,以提高资源利用率和系统吞吐量

实现多进程

在操作系统中能同时运行多个进程(程序)。

Linux 系统函数 fork() 可以在父进程中创建一个子进程。当程序接收到客户端的请求时,就可以让子进程来处理,父进程只负责监控请求的到来,这样就能实现并发处理。

pid = fork();

fork 函数会返回两次结果,因为操作系统会把当前进程的数据复制一遍,然后程序就分两个进程继续运行后面的代码。fork 函数分别在父进程和子进程中返回,在子进程中返回的值 pid 永远是 0,在父进程返回的是子进程的进程 id。在同一片代码块中,通常采用 if 语句判断进程号来区分不同进程的执行操作。

虽说每一次执行 fork() 函数,进程的所有数据都会被复制一遍,但 UNIX 实现了一个小技巧,在实现执行 fork() 时,实现的复制是“逻辑上”的复制,而不是“物理”上的,此时父进程与子进程依然共享者同一个数据段和堆栈段。当有一个进程写入数据时,这时两个进程的运行数据才开始分离开来。

exec() 函数族可以将当前进程替换成另一个指定的程序。一个进程一旦调用 exec 类函数,它本身就“死亡”了,系统会将代码段替换成新程序的代码,废弃原有的数据段和堆栈段,并为新程序分配新的数据段和堆栈段,唯一不变的只有进程号。也就是说,对系统而言,还是同一个进程,不过已经变成另一个程序了。

如果当前程序想在启动另一程序之后,仍然能保持运行的话,就可以结合 fork 和 exec 的使用,将子进程替换成指定的程序。

线程

引入线程的目的,是为了减少程序在并发执行时所付出的时空开销提高操作系统的并发性能。引入线程后,进程的内涵就发生了改变,进程基本只作为除 CPU 以外系统资源的分配单元,线程则作为处理机的分配单元。

线程是进程的一个实体,是 CPU 调度和分派的基本单位,它是比进程更小的、能独立调度的基本单位。线程自身基本上不拥有系统资源,只拥有一些在运行过程中必不可少的资源(如线程 ID、程序计数器、寄存器集合、堆栈等),但它可与同属一个进程的其他线程共享当前进程所拥有的全部资源。

与进程相比,线程是一个更加接近于执行体的概念,它可以与同进程的其他线程共享数据,但也拥有属于自己的栈空间,拥有独立的执行序列。一个线程可以创建和撤销另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。线程有就绪、阻塞、运行三种基本状态。

多线程同步机制

在同一个进程中,可以有多个线程同时执行。通过 CPU 调度,在每个时间片中只有一个线程执行。但多线程给程序引入了一定的不可预知性,为了加强控制,避免程序失去控制,我们常常选用一些合适的锁机制来实现数据同步。

互斥锁 Mutex Lock

互斥锁(Mutual-Exclude Lock)是最容易理解、使用最广泛的一种同步机制。使用互斥锁保护的临界区只允许一个线程进入,其他线程如果没有获取锁权限,就只能等候。

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); 
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex); 

基本上所有问题能可以用互斥锁来解决,但并不代表所有情况都适合采用这个方案。像是一些多资源分配、线程步调通知等场合,使用互斥锁就会由于等待互斥锁释放的时间太多而影响系统处理效率。所以,像一些读多写少的场合,就比较适合读写锁;临界区较短的场合,就比较适合自旋锁。

使用互斥锁需要考虑死锁的问题。单个互斥锁是不会引发死锁的,但进入一段临界区需要多个互斥锁时,就很容易导致死锁。解决的方法通常是,申请锁的时候按照固定顺序,及时释放不需要的互斥锁

读写锁 Reader-Writer Lock

读写锁,有时候也称共享锁(shared-Exclusive Lock)。在现实中,读取数据并不影响数据内容本身,而写操作则会对数据内容进行修改。因此使用读写锁可以减少互斥锁导致的阻塞延迟。

当一个线程加了读锁访问临界区,另一个线程也想访问的时候,也可以加一个读锁,然后进行读操作。当第三个贤臣需要进行写操作时,它需要加一个写锁,这个写锁只有在读锁的拥有者为 0 的时候才有效,也就是等前两个读锁都被释放之后,该线程才能开始进行写操作。

int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
// 阻塞请求。
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
// 非阻塞请求
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

实际上由于大部分系统默认以读进程优先,所以很容易就会出现写进程饥饿的情况,也就是它必须等到所有的读锁都释放之后,才能进行申请写锁。不同的系统的实现版本对读写的优先级实现不同,有的是写进程优先,有的是读进程优先。因此,最好的策略是考虑实际情况,设置适当的优先级。

自旋锁 Spin Lock

自旋锁是互斥锁、读写锁的基础。在互斥锁和读写锁申请加锁的时候,线程会被阻塞。阻塞的过程分为两个阶段,第一阶段会进行空转,相当于一个 while 循环,不断地去申请锁。在空转一定时间之后,线程就会进入 waiting 状态,此时线程就不占用 CPU 资源了。等到锁可用的时候,这个线程就会被重新唤醒。结合这两个阶段是为了考虑效率因素:

  • 如果在申请锁失败以后,立刻将线程状态挂起,那么会带来上下文切换的开销。但如果锁在第一次申请失败之后就可用了,那么短时间内进行上下文切换就会显得很没效率。
  • 如果在申请锁失败之后,依然不断地轮询申请加锁,那么可以避免上下文切换的开销,但浪费的宝贵的 CPU 时间。如果需要等待很长时间之后,锁才能申请成功,那么 CPU 长时间进行轮询就显得效率很低。
int pthread_spin_init (__pthread_spinlock_t *__lock, int __pshared);
int pthread_spin_destroy (__pthread_spinlock_t *__lock);
int pthread_spin_trylock (__pthread_spinlock_t *__lock);
int pthread_spin_unlock (__pthread_spinlock_t *__lock);
int pthread_spin_lock (__pthread_spinlock_t *__lock);

从自旋锁的特性来看,自旋锁非常适合临界区非常短的场合,或者实时性要求比较高的场合;如果临界区需要在中断上下文访问,则必须使用自旋锁。由于临界区短,线程需要等待的时间也短,即便轮询浪费 CPU 资源,也浪费不了多少,还省了上下文切换的开销。 由于实时性要求比较高,来不及等待上下文切换的时间,那就只能浪费 CPU 资源在那儿轮询了。

自旋锁是一种保护数据结构或者代码片段的原始方式,主要用于 SMP 中,用于 CPU 同步,在某个时刻只允许一个进程访问临界区内的代码。它的实现是基于 CPU 锁定数据总线的指令。

不过说实话,大部分情况都不会直接用到自旋锁,其他锁在申请不到加锁时也是会空转一定时间的。如果连这段时间都无法满足请求,那要么就是线程太多,或者临界区并没有想象的那么短。

信号量

信号量广泛用于进程或者线程间的同步与互斥,本质上是一个非负的整数计数器,它被用来控制对公共资源的访问。编程时可根据操作信号量值的结果判断是否对公共资源具有访问的权限,当信号量值大于 0 时,则可以访问,否则将阻塞。PV 原语是对信号量的操作,一次 P 操作使信号量减1,一次 V 操作使信号量加1

信号量适合于保持时间较长的场景,且共享的内存只能在进程上下文使用。信号量是进程级的,用于多个进程之间对资源的互斥。竞争不上的进程,会有上下文切换,进程可以去睡眠,但此时 CPU 不会停止,会接着运行其他的执行路径。这里跟单核 CPU 或者多核 CPU 没有直接的关系,只是在信号量的实现上,为了保证信号量结构存取的原子性,在多核 CPU 中需要自旋锁来实现互斥

内核信号量

由内核控制路径使用,只有可以睡眠的函数才能获取内核信号量。中断处理程序和可延迟函数都不能使用内核信号量。

struct semaphore {
   atomic_t count;
   int sleepers;
   wait_queue_head_t wait;
  }
POSIX信号量
无名信号量

使用方法跟使用一般的变量相同,直接声明即可。无名信号量常用于多线程间的同步,同时也用于相关进程间的同步。无名信号量直接保存在内存中。

int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_post(sem_t *sem);
int sem_destroy(sem_t *sem);
有名信号量

有名信号量要求创建一个文件。因此有名信号量既可以用于线程之间,也可以用于相关进程间,甚至是不想管的进程。一般来说,有名信号量用于进程间同步或者互斥。

无名信号量和有名信号量两者的区别与管道及命名管道的区别类似。

sem_t *sem_open(const char *name, int oflag, mode_t mode , int value);
二进制信号量与互斥信号量

二进制信号量是技术信号量中的一种特殊情况。二进制只能取 0 或者 1。两者实现互斥的方式不一样。对于互斥信号量,谁申请的锁谁释放,二进制信号量则不一定,可以由其他任务释放锁。

顺序锁

用于能够区分读与写的场合,并且是读操作很多、写操作很少,写操作的优先权大于读操作

  • seqlock 的实现思路是,用一个递增的整型数表示 sequence。
  • 写操作进入临界区时,sequence++;退出临界区时,sequence再++。写操作还需要获得一个锁(比如 mutex),这个锁仅用于写写互斥,以保证同一时间最多只有一个正在进行的写操作。
  • 当 sequence 为奇数时,表示有写操作正在进行,这时读操作要进入临界区需要等待,直到 sequence 变为偶数。读操作进入临界区时,需要记录下当前 equence 的值,等它退出临界区的时候用记录的 sequence 与当前 sequence 做比较,不相等则表示在读操作进入临界区期间发生了写操作,这时候读操作读到的东西是无效的,需要返回重试
RCU 锁

当读操作要调用 rcu_dereference 访问对象之前,需要先调用 rcu_read_lock;当不再需要访问对象时,调用rcu_read_unlock。

当写操作调用 rcu_assign_pointer 完成对对象的更新之后,需要调用 synchronize_rc u或 call_rcu。其中 synchronize_rcu会 阻塞等待在此之前所有调用了 rcu_read_lock 的读操作都已经调用 rcu_read_unlock, synchronize_rcu 返回后写操作一方就可以将被它替换掉的旧对象释放了;而 call_rcu 则是通过注册回调函数的方式,由回调函数来释放旧对象,写操作一方将不需要阻塞等待。同样,等到在此之前所有调用了 rcu_read_lock 的读操作都调用 rcu_read_unlock 之后,回调函数将被调用。

线程池

线程池是在 Java 中开辟出的管理线程的概念。线程池主要解决了以下的问题:

  1. 创建、销毁线程伴随着系统开销,过于频繁的创建、销毁线程,会很大程度上影响系统处理效率。
  2. 线程并发数量过多,抢占系统资源,从而导致系统阻塞。
  3. 能够方便地管理线程,如线程延迟执行、执行策略等。

Java 中常见的四种线程池包括:

  • CachedThreadPool()
  • FixedThreadPool()
  • ScheduledThreadPool()
  • SingleThreadExecutor()

进程与线程的对比

进程线程
调度资源拥有的基本单位
在不同进程中进行线程切换,会先引起进程切换。
独立调度的基本单位
在同一进程中,线程的切换不会引起进程切换
资源资源拥有的基本单位。每个进程都有独立的数据空间(程序上下文)拥有独立的运行栈(局部变量)和程序计数器;可以访问其隶属进程的系统资源。
并发性进程间并发执行。线程间并发执行。
系统开销创建于撤销进程时,系统都要分配或者回收资源,因此操作系统会付出极大的开销;进行进程切换时,涉及到当前进程 CPU 环境的保存以及新调度的进程 CPU 环境的设置。线程切换时,只需要保存和设置少量寄存器内容,开销很小。
地址空间进程间的地址空间相互独立。同一进程内的各线程共享进程的资源;进程内线程对于其他进程不可见。
通信进程间通信(IPC)需要进程同步和互斥手段的辅助,以保证数据的一致性。线程间通信可以直接读写进程数据段(如全局变量、静态变量)来进行通信。

协程

协程(Coroutine)又称为微线程、纤程,近几年在某些编程语言(如 Lua)中开始得到广泛应用。协程看上去有点像子程序。子程序是通过栈实现的,执行一次,则会有一个入口,一次返回,调用顺序明确。但协程在执行过程中,可以在子程序内部中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。

需要注意的是,子程序是函数的调用,协程则类似于 CPU 中断。

协程只在一个线程内执行,与多线程相比,它有极高的执行效率。因为携程当中的子程序切换不是线程切换,而是程序自身控制的切换,因此并没有线程切换的开销。另一方面,协程不需要多线程的锁机制,也不存在读写冲突,在协程中控制共享资源只需要判断状态即可。

在多核 CPU 环境下,可以采用多进程 + 协程的方式,既能利用多核,又能充分发挥协程的高效率,可以获得极高的性能。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值