线程同步与进程同步方式

发布时间 2023-09-18 10:38:43作者: tomato-haha

要注意这里的同步并不是指同时进行的意思,而是按照先后顺序依次进行。
首先了解一下同步与互斥的概念:

  • 同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系;
  • 互斥:多个进程在同一时刻只有一个进程能进入临界区。

一、进程同步方式

进程同步就是控制多个进程按一定顺序执行,而进程间通信(IPC)是在进程间传输信息。它们之间的关系是:进程通信是一种手段,而进程同步是一种目的,为了能够达到进程同步的目的,需要让进程进行通信,传输一些进程同步所需要的信息。借用知乎某佬的一句话:

不要当那种考试考傻了的书呆子,把“同步”和“通信”的概念分得那么清楚,能通信就一定是一种同步机制,这是显而易见的。

所以面试官问你进程同步的方式时,他的意思其实是让你回答进程间的通信方式。
常用的进程间通信方式:管道,共享内存,消息队列,信号量,套接字。

1)管道

管道分为命名管道和匿名管道,命名管道可以用于两个或任意多个进程间通信,匿名管道则只能用于有血缘关系(父子进程、兄弟进程、爷孙进程等)的进程间通信。Linux中的“|”命令就是匿名管道,表示把一个进程的输出作为另一个进程的输入。管道就是内核里的一段缓存,从管道一端写入的数据实际上是缓存在内核中,从另一端读取也就是从内核中读取这段数据。管道是半双工的,数据只能向一个方向流动,双方需要互相通信时,需要建立起两个管道。

2)共享内存

共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的一种IPC方式,因为没有内存拷贝的操作,但需要依靠互斥锁或信号量来实现同步。

3)信号

信号是Linux系统中的进程间通信方式,信号可以在任何时候发给某一进程,用于通知该进程某个事件已经发生。比如kill -9命令就可以向指定的进程发送一个终止信号从而杀死进程。

4)信号量

信号量本质就是一个计数器,记录资源能被多少个进程同时访问,用来实现进程之间的互斥与同步,信号量的引入的是为了解决共享内存通信方式造成的进场安全问题。

5)消息队列

多个不相干的进程可以通过一个消息队列来传递数据,且传递的是一个有意义的数据结构,而管道只能传递没有意义的字节流,还需要在接收端做解析。消息队列和管道一样是有一个buffer size限制的,当buffer size 为空或为满的时候,send和receive会sleep。

ssize_t msgrrcv(int msqid, void *ptr, size_t nbytes, long type, int flag);

消息队列的目的是提供高于一般速度的IPC,但现在与其他形式的IPC相比,速度方面已经没有什么差别,在考虑到使用消息队列时遇到的问题,在新的应用程序中不应当再使用它们。

6)套接字

可用于不同主机之间的进程间通信。


二、线程同步方式

对于多线程访问共享资源出现数据混乱的问题,需要进行线程同步。所谓的共享资源就是多个线程共同访问的变量,这些变量通常为全局数据区变量或者堆区变量,这些变量对应的共享资源也被称之为临界资源。
常用的线程同步方式有四种:互斥锁、读写锁、条件变量、信号量。

1)互斥锁

互斥锁是线程同步最常用的一种方式,通过互斥锁可以锁定一个代码块,对于被锁定的这个代码块,所有的线程只能串行处理,不能并行处理。

相关函数:

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_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
                    //所有函数成功返回0,出错返回错误编号

在使用互斥量之前需要对它进行初始化,然后调用 pthread_mutex_lock 对互斥量上锁,若互斥量已经上锁,调用线程将阻塞直到互斥量被解锁,如果不希望线程被阻塞,可以调用 pthread_mutex_trylock 尝试对互斥量进行加锁,最后调用 pthread_mutex_unlock 对互斥量解锁。

2)读写锁

读写锁是互斥锁的升级版,所有线程的读操作都是并行的,只有写操作是串行的。程序中的读操作越多,读写锁性能就越高,相反,若程序中全是写操作,那么读写锁会退化成互斥锁。

相关函数:

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);
                    //成功返回0,出错返回错误编号

可以指定加读锁或者写锁,不管以何种方式锁住读写锁,都可以调用 pthread_rwlock_unlock 进行解锁。

3)条件变量

条件变量的主要作用不是处理线程同步,而是进行线程的阻塞。常常和互斥锁一起用在生产者和消费者模型中。举个例子:当任务队列为空时,消费者无法消费,使用条件变量把消费者线程阻塞住,等待生产者生产出任务后,再唤醒一个或多个被条件变量阻塞的消费者线程;反过来也可以控制生产者生产的上限,当任务队列达到一个上限值时用条件变量阻塞住生产者线程,直到消费者把任务消费后再唤醒被条件变量阻塞的生产者线程。

相关函数:

int pthread_cond_init(pthread_cond_t *restrict cond,
                        const pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
                    //成功返回0,出错返回错误编号

4)信号量

信号量可以实现线程同步也可以实现进程同步,它允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。信号量主要阻塞线程,不能完全保证线程安全,如果要保证线程安全,需要和互斥锁一起使用。信号量也可以用来实现生产者消费者模型,在用条件变量实现生产者消费者模型时需要自己做条件判断,而使用信号量就不需要。举个例子:初始化生产者的信号量为5,消费者的信号量为0,因为消费者信号量为0所以会被阻塞,生产者进行一次生产后会将自己的信号量减1,将消费者信号量加1,这时消费者解除阻塞,进行消费后再将自己的信号量减1生产者信号量加1。

相关函数:

int sem_init(sem_t* sem, int pshared, unsigned int value);  //信号量对象地址,是否多进程共享,初始状态下资源数量
int sem_destroy(sem_t* sem);    //销毁信号量
int sem_post(sem_t* sem);       //将信号量资源计数递增1并唤醒线程
int sem_wait(sem_t* sem);       //若当前信号量为0,则阻塞线程,直到信号量对象的资源计数大于0时被唤醒,唤醒后资源计数递减1
int sem_trywait(sem_t* sem);    //非阻塞版本
int sem_timedwait(sem_t* sem, const struct timespec* abs_timeout);  //带等待时间的版本

5) 自旋锁

自旋锁与互斥锁类似,但它不是通过休眠使进程阻塞,而是在获取锁之前一直处于忙等(自旋)阻塞状态。自旋锁可用于一下情况:锁被持有的时间短,而且线程并不希望在重新调度上花费太多成本。

相关函数:

int pthread_spin_init(pthread_spinlock_t *lock, int pshared);
int pthread_spin_destroy(pthread_spinlock_t *lock);
int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);
                    //成功返回0,出错返回错误编号

6) 屏障

屏障允许每个线程等待,直到所有的合作线程都到达某一点,然后从该点继续执行。pthread_join函数就是一种屏障,允许一个线程等待,直到另一个线程退出,但屏障允许任意数量的线程等待,直到所有的线程完成处理工作,而线程不需要退出,所有线程达到屏障后可以接着工作。

函数原型:

int pthread_barrier_init(pthread_barrier_t *restrict barrier,
        const pthread_barrierattr_t *restrict attr, unsigned int count);
int pthread_barrier_destroy(pthread_barrier_t *barrier);
int pthread_barrier_wait(pthread_barrier_t *barrier);
                    //成功返回0,出错返回错误编号
  • count指定在允许所有线程继续运行之前,必须到达屏障的线程数目;
  • 调用pthread_barrier_wait的线程在屏障计数,未满足条件时会进入休眠状态。如果该线程是最后一个调用pthread_barrier_wait的线程,就满足了屏障计数,所有的线程都被唤醒。

补充一个死锁的概念:当多个线程访问共享资源时,需要加锁,如果锁使用不当,就会造成死锁,导致所有的线程都被阻塞,并且线程的阻塞无法解开。如果所有线程总是在对互斥量B加锁之前锁住互斥量A,那么使用这两个互斥量就不会产生死锁,可能出现的死锁只会发生在一个线程试图锁住另一个线程以相反的顺序锁住互斥量。
造成死锁的场景有:
①、加锁后忘记解锁;
②、重复加锁造成死锁;
③、程序中存在多个共享资源,有多把锁,随意加锁导致相互被阻塞。
解决方式:
①、多检查,避免重复加锁;
②、资源访问完毕后一定要解锁,或者在访问时使用trylock(),因为trylock()在加锁失败时不会一直阻塞,而是返回错误号。

参考文献

Alan_操作系统知识点总结
苏丙榅的博客
《现代操作系统》