Linux 并发编程小结

一、可重入函数:

可重入函数遵循以下三个特点。

1.函数中不能使用任何非const的静态或者全局变量。这个比较好理解,共享的变量要保证是无法被修改的,大家都只作读取操作,自然没有竞争的问题。

2.不能产生任何“副作用”, 即不能对所处的环境产生影响。修改磁盘上的共享文件,依赖于环境变量.....总之,这个函数是完全孤立的,不会改变任何运行环境中的因素,这也包括使用定义在局部的static变量,这种变量将这个函数自己的运行环境改变了。

3.不能调用其他的不可重入函数。

所述的第一点和第三点较容易理解,第二点其实在强调上下文环境在可重入函数实现中的重要性。例如下面的示例代码:


可以看出,上述代码如果在多线程的环境下执行,可能会带来严重的问题。
另外,值得强调的是,代码中不能使用非const的静态变量这一条内容务必是强制性要求,不能忽视,例如一种单例模式(singleton)的实现方法如下所示:


此代码的开发人员想通过static对象的方式避免在代码中使用double-check的方式来提供函数的可重用语义功能,供在多线程的场合下使用。这种局部静态对象是一种lazy initialization的方式,其语义为当函数开始执行时完成对象的创建,为了达到C标准规定语义的要求,编译器通常提供了类似下面的实现方式(使用伪代码进行描述):


     以粉红色背景显示的伪代码提供了编译器一种可能的实现,我们可以在gcc下进行验证,将上面的代码编译为汇编代码,我们查看gcc如何进行的处理:


     从所附的汇编代码可以看出,gcc提供的实现与前述使用C++描述的伪代码的执行逻辑相同。因此,在函数中使用局部静态对象是不可重入的。静态对象是一个很微妙的事物,语言本身为其提供了灵活而且强大的功能,但在使用的过程中如果不注意细节也很容易出现一些问题。


二、线程安全

    可重入函数一定为线程安全函数。 通常情况下,可以使用线程的互斥机制,来保证安全。

    linux提供了sig_ atomic _t数据类型,该类型定义为int,实际上是一种weak atomic 数据类型,只能执行一些非常受限的原子操作。 sig_ atomic _t类型的变量只保证特定的操作为原子操作,实际上操作的原子性是由底层的硬件平台保障的,即基于比机器字长短的数据类型的操作一般都为原子操作。我们可以在代码中使用test and set机制实现基于多线程的同步:


     busy-loop, 性能不太好,最好不要使用。


三、信号安全:

1. 使用信号安全函数:

      linux提供了signal和sigaction两个信号初始化函数,相比较而言,sigaction的可移植性更好,另外功能上也有所扩充,例如可以指定信号处理函数执行期间可屏蔽的其他信号。对于NPTL线程库而言,若主线程存在,发生的信号将在主线程的上下文中响应,否则,运行库将挑选一个线程作为信号处理函数的运行环境。

      如前所述,信号与线程同为可并发的执行序列,但在执行方式上具有显著不同,当信号被阻塞时,并不会引起上下文的切换,也就是说不会发生线程的切换,信号安全类的函数相对于线程安全函数来说具有更严格的要求。
      例如,glibc提供的malloc、printf等函数都属于线程安全函数,其内部使用互斥锁的方式对使用到的全局数据结构进行保护,因此可以在多线程的环境下使用,但所述函数不属于信号安全的范畴,如果在信号处理函数和线程中同时执行,有可能产生死锁,例如:


      因此一个常见的设计约束为在信号处理函数中不能使用任何有可能导致发生阻塞的库函数,我们可以通过在函数中屏蔽指定的信号来达到信号安全的目的,linux提供的sigaction系统调用可以完成这一功能。可以参看APUE里面的信号安全函数。 


       如果需要在信号处理函数中调用全局变量,一定要使用volatile修饰,并且尽可能的使用sig_atmic_t 变量。


      POSIX规定,当系统调用(system call)在执行的过程中被信号中断时,应返回错误值,并将指示错误状态的全局变量errno设置为EINTR。 google维护的开源浏览器项目chrome的开发者邮件列表中对可能返回EINTR错误类型的函数进行了整理:

  * read, readv, write, writev, ioctl
  * open() when dealing with a fifo
  * wait*
  * Anything socket based (send*, recv*, connect, accept etc)
  * flock and lock control with fcntl
  * mq_ functions which can block
  * futex
  * sem_wait (and timed wait)
  * pause, sigsuspend, sigtimedwait, sigwaitinfo
  * poll, epoll_wait, select and 'p' versions of the same
  * msgrcv, msgsnd, semop, semtimedop
  * close (although, on Linux, EINTR won't happen here)
  * any sleep functions (careful, you need to handle this are restart with
    different arguments)
因此,对于以上函数,根据程序所完成功能的需要,开发人员应正确进行处理,例如可以采取下面的宏简化处理方式:


该宏使用了gcc的扩展关键字typeof用来获得指定函数的类型,使用时可以采用如下调用方式:


linux提供的系统调用sigaction可以改变针对特定信号中断时系统调用的行为为BSD风格的restart,即若产生信号中断事件,系统调用将被重置。

int
main(int argc, char **argv)
{
    struct sigaction act;
    act.sa_handler = ouch;
    act.sa_flags = SA_RESTART;  //设置重启
    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask, SIGTERM);

    if(-1 == sigaction(SIGTERM, &act, 0)){
        perror("sigaction\n");  //捕捉SIGTERM   
    }   
}
值得注意的是,部分与时间相关的系统调用并不在设置SA_RESTART标志位影响的范围之内,这一类系统调用包括select、connect以及nanosleep函数等,太吐槽了,为什么这么干。。。

四、虚假唤醒

当线程通过等待函数进行等待时,可能因为发生信号导致等待函数返回。例如pthread_cond_wait函数,:


       可以看出,linux提供的信号等待同步函数不会返回EINTR类型的错误。
       “虚假唤醒”还包括另外一个方面的内容,主要指条件变量wait和signal操作之间的不匹配,解决的方法通常是采用如下的编码风格:

//条件等待变量:
pthread_mutex_lock(&m);
while(!condition)
    pthread_cond_wait(&cond, &m);
//Thread stuff here
pthread_mutex_unlock(&m);

//条件变量释放
pthread_mutex_lock(&m);
condition = true;
pthread_cond_signal(&cond);
pthread_mutex_unlock(&e);

       这种编程方式主要解决了先释放信号再等待信号这种不同步可能导致应用程序陷入死锁的问题。如果只有一个signal条件变量的线程,等待代码中的while循环可以调整为if语句。


五、fork引发的问题:

       linux提供的系统调用fork完成子进程创建的任务,创建后的子进程完全继承父进程的内存布局,但并不会继承创建子进程是父进程所处的多线程的运行环境。换一种思路理解起来更为容易,fork api为为操作系统调用,而多线程是以运行库的方式提供的,因此fork创建的子进程并不会继承父进程的线程情况,换言之不论父进程是否使用了多线程,创建的子进程都将采用单线程的执行方式。
       由于系统调用fork实现方式的原因,子进程的代码与父进程的代码将重用相同的源文件,如果父进程采用了多线程的实现方式,那么子进程不应依赖于父进程所采用的多线程控制结构,否则容易出现问题,例如如下所示的代码片段:

class lock_t{
public:
    lock_t(){
        pthread_mutex_init(&_mutex, NULL);
    }

    void
    lock()
    {
        pthread_mutex_lock(&_mutex);
    }

    void
    unlock()
    {
        pthread_mutex_unlock(&_mutex);
    }
private:
    pthread_mutex_t _mutex;
}g_lock;

void
critical_section()
{

}

void*
self_routine(void*)
{
    g_lock.lock();
    critical_section();
    g_lock.unlock();
}

int 
main(int argc, char **argv)
{
    pthread_t pid;
    if(0 != pthread_create(&tid, NULL, self_routine, NULL))
    {
        return -1;
    }

    sleep(1);
    pid_t new_pid = fork();
    if(new_pid == -1)
    {
        return -1;
    } else if(new_pid == 0){
        g_lock.lock();
        critical_section();
        g_lock.unlock();
    } else {
        ...
    }
}

       从上面的代码可以看出,fork系统调用将在创建的线程之后运行,若线程执行到加锁时切换到主线程,主线程将开始执行fork创建子进程,根据前面内容的描述,fork将复制父进程的内存到子进程当中,此时已加了锁的pthread_mutex_t类型变量将被完整的复制到子进程,当子进程执行上面标记为红色的代码重新开始获取锁时,因获取不到所以将被无限期的挂起。
       以上代码给出的是直接使用mutex的方式,glibc提供的很多库函数(例如printf)为了确保线程安全的特性大都在内部使用了互斥锁,对于这一类函数在fork创建的子进程中使用会出现相同的问题。


  通过以上内容的描述,可以得出以下结论:
       1) 可重入函数一定是线程安全函数,也一定是信号安全函数。
       2) 不可重入函数可以通过在函数内增加互斥机制成为线程安全函数。
       3) 满足线程安全不一定能够满足信号安全。例如:
            ※errno是线程安全的全局变量,其实现原理为通过NPTL提供的线程局部存储功能完成,当发生上下文切换时,被切换线程与切换线程的errno被保存和恢复。
            ※内部使用了同步互斥机制的函数是线程安全的,但一定不是信号安全的,如果在信号处理函数中使用可能会造成死锁
       4) 信号安全的函数也不一定是线程安全函数
       5) fork安全与信号安全在问题产生的机理方面具有一定的相似程度。

你可能感兴趣的:(Linux,系统编程,linux,多线程,signal,initialization,gcc,编译器)