菜鸟笔记
提升您的技术认知

linux进程及进程间同步通信机制

进程

一、进程的存储器安排

这是《UNIX环境高级编程》中介绍的典型存储器安排,但现实也不一定非要如此。对于c/c++来说,数据的存储方式还是认为3种:堆、栈、全局数据区(包括全局数据、静态数据、常量)。

二、僵尸进程和孤儿进程

  进程在终止前向父进程发送SIGCLD信号,父进程调用wait等待子进程的退出!

  孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

  僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程(ps进程状态为“Z”)。

  孤儿进程没有什么危害,因为将来结束时,init进程会完成他们状态收集的工作。而僵尸进程在内核中还有一些信息(包括进程号),需要及时处理;但是每个进程都要或长或短地经历僵尸进程的状态。

三、fork和vfork

  调用fork一次返回2次,分别在父进程和子进程中返回,父进程中其返回值是子进程的进程标识符,子进程中其返回值是0。

  fork创建一个进程时,子进程复制父进程的数据段,堆栈,共享父进程的代码段。但是为了降低开销,现在Linux中是采取了copy-on-write(COW写时复制)技术,只有在写入的时候,才复制写入的那块内存,一般为虚拟内存的一“页”(即部分复制)。

  vfork,1、保证子进程先运行,即在调用exec和exit之前父进程不运行,也就是如果子进程依赖父进程的进一步动作,将陷入死锁。2、在调用exec和exit之前,子进程和父进程是共用数据段和代码段的,也就是子进程的数据更改会影响父进程。

四、进程间的通信

  1. 管道(Pipe)及有名管道(named pipe):管道可用于具有亲缘关系进程间的通信,有名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信;
  2. 信号(Signal):信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程本身;linux除了支持Unix早期信号语义函数sigal外,还支持语义符合Posix.1标准的信号函数sigaction(实际上,该函数是基于BSD的,BSD为了实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数);
  3. 报文(Message)队列(消息队列):消息队列是消息的链接表,包括Posix消息队列system V消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  4. 共享内存:使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。
  5. 信号量(semaphore):主要作为进程间以及同一进程不同线程之间的同步手段。
  6. 套接口(Socket):更为一般的进程间通信机制,可用于不同机器之间的进程间通信。

      (1) 信号

---- 信号机制是UNIX为进程中断处理而设置的。它只是一组预定义的值,因此不能用于信息交换,仅用于进程中断控制。例如在发生浮点错、非法内存访问、执行无 效指令、某些按键(如ctrl-c、del等)等都会产生一个信号,操作系统就会调用有关的系统调用或用户定义的处理过程来处理。

---- 信号处理的系统调用是signal,调用形式是:

---- signal(signalno,action)

---- 其中,signalno是规定信号编号的值,action指明当特定的信号发生时所执行的动作。

     (2) 无名管道和有名管道

---- 无名管道实际上是内存中的一个临时存储区,它由系统安全控制,并且独立于创建它的进程的内存区。管道对数据采用先进先出方式管理,并严格按顺序操作,例如不能对管道进行搜索,管道中的信息只能读一次。

---- 无名管道只能用于两个相互协作的进程之间的通信,并且访问无名管道的进程必须有共同的祖先。

---- 系统提供了许多标准管道库函数,如:

pipe()——打开一个可以读写的管道;

close()——关闭相应的管道;

read()——从管道中读取字符;

write()——向管道中写入字符;

---- 有名管道的操作和无名管道类似,不同的地方在于使用有名管道的进程不需要具有共同的祖先,其它进程,只要知道该管道的名字,就可以访问它。管道非常适合进程之间快速交换信息。

     (3) 消息队列(MQ)

---- 消息队列是内存中独立于生成它的进程的一段存储区,一旦创建消息队列,任何进程,只要具有正确的的访问权限,都可以访问消息队列,消息队列非常适合于在进程间交换短信息。

---- 消息队列的每条消息由类型编号来分类,这样接收进程可以选择读取特定的消息类型——这一点与管道不同。消息队列在创建后将一直存在,直到使用msgctl系统调用或iqcrm -q命令删除它为止。

---- 系统提供了许多有关创建、使用和管理消息队列的系统调用,如:

---- int msgget(key,flag)——创建一个具有flag权限的MQ及其相应的结构,并返回一个唯一的正整数msqid(MQ的标识符);

---- int msgsnd(msqid,msgp,msgsz,msgtyp,flag)——向队列中发送信息;

---- int msgrcv(msqid,cmd,buf)——从队列中接收信息;

---- int msgctl(msqid,cmd,buf)——对MQ的控制操作;

    (4) 共享存储段(SM)

---- 共享存储段是主存的一部分,它由一个或多个独立的进程共享。各进程的数据段与共享存储段相关联,对每个进程来说,共享存储段有不同的虚拟地址。系统提供的有关SM的系统调用有:

---- int shmget(key,size,flag)——创建大小为size的SM段,其相应的数据结构名为key,并返回共享内存区的标识符shmid;

---- char shmat(shmid,address,flag)——将当前进程数据段的地址赋给shmget所返回的名为shmid的SM段;

---- int shmdr(address)——从进程地址空间删除SM段;

---- int shmctl (shmid,cmd,buf)——对SM的控制操作;

---- SM的大小只受主存限制,SM段的访问及进程间的信息交换可以通过同步读写来完成。同步通常由信号灯来实现。SM非常适合进程之间大量数据的共享。

     (5) 信号灯

---- 在UNIX中,信号灯是一组进程共享的数据结构,当几个进程竞争同一资源时(文件、共享内存或消息队列等),它们的操作便由信号灯来同步,以防止互相干扰。

---- 信号灯保证了某一时刻只有一个进程访问某一临界资源,所有请求该资源的其它进程都将被挂起,一旦该资源得到释放,系统才允许其它进程访问该资源。信号灯通常配对使用,以便实现资源的加锁和解锁。

     进程间通信的实现技术的特点是:操作系统提供实现机制和编程接口,由用户在程序中实现,保证进程间可以进行快速的信息交换和大量数据的共享。

五、线程

使用:

头文件:#include <pthread.h>

创建:

pthread_t pid;

void * func(void *arg){}

pthread_create(pid,NULL,func,NULL);

结束:

pthread_exit(0);或者运行结束

主线程阻塞等待子线程:

pthread_join(pid);防止主线程结束,子线程没机会运行。同时还能获取子线程的退出状态。

分离:

pthread_detach();使线程分离,这样运行结束后,存储器资源会被立刻收回。

子线程调用:

pthread_detach(pthread_self());

或 父进程调用:

pthread_detach(tid); //此函数是非阻塞函数,立刻返回。

子线程分为分离状态和join状态,join状态子线程运行结束后,一些资源不会释放,调用pthread_join后,资源才释放。分离状态的子线程运行结束后,资源被自动回收。

 

1、线程优点:

所有线程可以直接共享内存和变量等(线程的栈是单独分配的,但是也是在同一地址空间); (与多进程相比,缺点是线程同步需要锁,线程出问题可能影响整个程序)

有的问题通过分解任务从而改变整个程序的吞吐量。(与单进程相比)

注意:linux中,进程和线程实现方式一样(只不过线程和其父进程共享了一部分数据),所以线程和进程的性能,上下文切换的消耗是差不多的。

2、多进程的优点:

不需要线程同步的锁;

各进程独立,不互相影响。

3、线程同步:

多个线程共享相同内存时,需要确保每个线程看到一致的数据。linux基本的同步机制:

一、互斥量(mutex)

  互斥量本质上是一把锁,在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量上的锁。

  对互斥量进行加锁以后,任何其它试图再次对互斥量加锁的线程将会被阻塞直到当前线程释放该互斥锁。如果释放互斥锁时有多个线程阻塞,所有在该互斥锁上的阻塞线程都会变成可运行状态,第一个变为运行状态的线程可以对互斥量加锁,其它线程将会看到互斥锁依然被锁住,只能回去再次等待它重新变为可用。在这种情况下,每次只有一个线程可以向前执行。

使用:

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t * restrict arr);

int pthread_mutex_destroy(pthread_mutex_t *mutex);

互斥量在使用前一定要初始化,对于动态分配的互斥量在释放内存前,要调用destroy。

int pthread_mutex_lock(pthread_mutex_t *mutex);

int pthread_mutex_unlock(pthread_mutex_t *mutex);

二、自旋锁(spin)

  当临界区是一段很小的代码,线程进入临界区,马上就会退出。这时,如果使用互斥量,会导致大量的上下文切换,所以就用了自旋锁。

  自旋锁的使用与互斥量类似,但是等待自旋锁时,进程不会释放cpu,而是一直占用cpu。所以自旋锁适用情况是临界区的代码不会执行时间太长,而且竞争不是太激烈。否则大量等待自旋锁的线程就像死锁一样。

使用:

  1. #include <linux/spinlock.h>  
  2. void spin_lock_init(spinlock_t *lock); 
  3. void spin_lock(spinlock_t *lock);
  4. void spin_unlock(spinlock_t *lock); 

内核中也有自旋锁,在linux下提供了应用层的自旋锁。

三、读写锁

  读写锁与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态要么是不加锁状态,而且一次只有一个线程可以对其加锁。

  读写锁可以由三种状态:读模式下加锁状态写模式下加锁状态不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。

  在读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是如果线程希望以写模式对此锁进行加锁,它必须阻塞直到所有的线程释放读锁。虽然读写锁的实现各不相同,但当读写锁处于读模式锁住状态时,如果有另外的线程试图以写模式加锁,读写锁通常会阻塞随后的读模式锁请求。这样可以避免读模式锁长期占用,而等待的写模式锁请求一直得不到满足。

  读写锁非常适合于对数据结构读的次数远大于写的情况。当读写锁在写模式下时,它所保护的数据结构就可以被安全地修改,因为当前只有一个线程可以在写模式下拥有这个锁。当读写锁在读状态下时,只要线程获取了读模式下的读写锁,该锁所保护的数据结构可以被多个获得读模式锁的线程读取。

1) 初始化和销毁

#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
成功则返回0, 出错则返回错误编号.

2) 读加锁和写加锁
获取锁的两个函数是阻塞操作

#include <pthread.h>
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, 出错则返回错误编号.

一般情况下,使用上述3中锁,实现线程同步。还有两种同步机制:

四、条件变量(condition)

  条件变量与互斥量一起使用时,允许线程等待特定的条件发生。

如果线程正在等待共享数据内某个条件出现,那会发生什么呢?代码可以反复对互斥对象锁定和解锁, 以检查值的任何变化。同时,还要快速将互斥对象解锁,以便其它线程能够进行任何必需的更改。需要一种方法以唤醒因等待满足特定条件而睡眠的线程。

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

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;/*初始化互斥锁*/
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;/*初始化条件变量*/

void *thread1(void *);
void *thread2(void *);

int i=1;
int main(void)
{
    pthread_t t_a;
    pthread_t t_b;

    pthread_create(&t_a,NULL,thread2,(void *)NULL);/*创建进程t_a*/
    pthread_create(&t_b,NULL,thread1,(void *)NULL); /*创建进程t_b*/
    pthread_join(t_b, NULL);/*等待进程t_b结束*/
    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);
    exit(0);
}

void *thread1(void *junk)
{
    for(i=1;i<=9;i++) 
    {
        pthread_mutex_lock(&mutex);/*锁住互斥量*/
        if(i%3==0)
             pthread_cond_signal(&cond);/*条件改变,发送信号,通知t_b进程*/
        else        
             printf("thead1:%d/n",i);
        pthread_mutex_unlock(&mutex);/*解锁互斥量*/

sleep(1);
}

}

void *thread2(void *junk)
{
    while(i<9)
    {
        pthread_mutex_lock(&mutex);

if(i%3!=0)
            pthread_cond_wait(&cond,&mutex);/*等待*/
        printf("thread2:%d/n",i);
        pthread_mutex_unlock(&mutex);

sleep(1);
}

}

 

备注:

初始化,对于静态的变量可以直接使用宏,如果是动态的,一定要使用pthread_cond_init来初始化。

 pthread_cond_wait 执行的流程首先将这个mutex解锁, 然后等待条件变量被唤醒, 如果没有被唤醒, 该线程将一直休眠, 也就是说, 该线程将一直阻塞在这个pthread_cond_wait调用中, 而当此线程被唤醒时, 将自动将这个mutex加锁,然后再进行条件变量判断(原因是“惊群效应”,如果是多个线程都在等待这个条件,而同时只能有一个线程进行处理,此时就必须要再次条件判断,以使只有一个线程进入临界区处理。),如果满足,则线程继续执行,最后解锁,

 

也就是说pthread_cond_wait实际上可以看作是以下几个动作的合体:
解锁线程锁
等待线程唤醒,并且条件为true
加锁线程锁.

 

 pthread_cond_signal仅仅负责唤醒正在阻塞在同一条件变量上的一个线程,如果存在多个线程,系统自动根据调度策略决定唤醒其中的一个线程,在多处理器上,该函数是可能同时唤醒多个线程,同时该函数与锁操作无关,解锁是由pthread_mutex_unlock(&mutex)完成

五、信号量:

头文件semaphore.h。允许多个进程进入临界区。

主要用到的函数:

  • int sem_init(sem_t *sem, int pshared, unsigned int value);,其中sem是要初始化的信号量,pshared表示此信号量是在进程间共享还是线程间共享,value是信号量的初始值。
  • int sem_destroy(sem_t *sem);,其中sem是要销毁的信号量。只有用sem_init初始化的信号量才能用sem_destroy销毁。
  • int sem_wait(sem_t *sem);等待信号量,如果信号量的值大于0,将信号量的值减1,立即返回。如果信号量的值为0,则线程阻塞。相当于P操作。成功返回0,失败返回-1。
  • int sem_post(sem_t *sem); 释放信号量,让信号量的值加1。相当于V操作。

 无名信号量<semaphore.h>,

  有名信号量<sys/sem.h>

  无名信号量不能用进程间通信,用于线程间通信;有名信号量可用于进程和线程,但一般用于进程。

  linux中,sem_init的第二个参数必须为0,表示线程间通信。