Linux多线程总结

1 进程环境

 

C程序总是从main函数开始执行。main函数的原型是:

     int main( int argc,  char* argv[]); 

      当内核执行C程序时(使用一个exec函数),在调用main前先调用一个特殊的启动例程。启动例程从内核取得命令行参数和环境变量值,然后调用main函数。

1.1 进程终止

 

有8种方式能种子进程,其中5种是正常终止,3种是异常终止。

正常:

  • 从main返回;
  • 调用exit;
  • 调用_exit或_Exit;
  • 最后一个线程从其启动例程返回;
  • 从最后一个线程调用pthread_exit。

异常:

  • 调用abort;
  • 接到一个信号;
  • 最后一个线程对取消请求做出响应。

    其中三个正常终止函数:_exit、_Exit和exit之间是有区别的。_exit、_Exit立即退出;而exit则先执行一些清理工作,然后再退出。

    并且可以利用函数atexit来声明在exit退出前会被调用的函数(称为终止处理程序),atexit原型为:

  int atexit(  void (*func)( void) ); 
                                 返回值:若成功返回0,若出错返回非0;

 如下图所示的一个C程序是如何启动和终止的:

Linux多线程总结_第1张图片

    注意:内核使程序执行的唯一方法是调用一个exec函数。进程自愿终止的唯一方法是显示或隐式地(通过调用exit)滴啊用_exit或_Exit;进程也可非自愿地有 一个信号使其终止。

1.2 进程内存布局

 

每个进程所分配的内存由很多部分组成,通常称之为"段"。如下所示:

  • 正文段:包含了进程运行的程序机器语言指令,此段具有只读属性。
  • 初始化数据段:包含显示初始化的全局变量和静态变量
  • 为初始化数据段:包含未显示初始化的全局变量和静态变量,系统为此段所有内存初始化为0。
  • 栈:自动变量以及每次函数调用时所需保存的信息都存放在此段中,包括局部变量、实参、返回值、临时变量和环境信息。
  • 堆:通常在堆中进程动态存储分配

 

Linux多线程总结_第2张图片

Figure 1 典型的存储空间安排

2 进程控制

 

    每个进程都有进程ID,其中ID为0的进程是调度进程(交换进程);进程为1的是init进程,并且init进程决不会终止,

2.1 进程的创建

 

    进程的创建可以通过函数fork和vfork进行。

重点注意:

不同的进程拥有不同的地址空间。如在不同的两个进程都拥有100的地址,那么这两个100存放的值是不一样的。

2.1.1 fork函数 

pid_t fork( void
                返回值:子进程返回0,父进程返回子进程的ID;若出错返回- 1

    fork函数被调用一次,却返回两次,子进程返回0,父进程返回子进程的ID值;并且子进程的内存是父进程的完全副本,包括局部、全局、静态的数据空间、堆和栈的副本。其中有些系统对fork进行了优化,即写时复制(Copy-On-Write)技术,在fork调用之后父子进程共享同一个区域,只有当父进程或子进程的任意一方试图修改这些区域,内核才会为修改区域的那块内存制作一个副本。

1) 缓冲问题

在fork函数调用之前,需要考虑缓冲中是否有数据存在。是为不带缓冲的系统调用,还是带缓冲的标准I/O。

  • 如果在调用fork函数之前,调用了不带缓冲的系统函数(如write),则只输出一次之前的数据;
  • 如果在调用fork之前,调用标准I/O(带缓冲函数,如printf),则可能输出会输出多次,因为父子进程都保存了缓冲中的数据,如果此时是与终端交互,则是行缓冲,而如果是磁盘交互,则是全缓冲。

2) 文件共享

    在fork调用之后,子进程复制了父进程文件描述符,并且子进程和父进程共享该文件的偏移量。若父子进程之间没有任何形式的同步,那么它们的输出就会相互混合。在fork之后处理文件描述符有以下两种使用模式:

  • 父进程等待子进程完成:父进程无需对其描述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享描述符的偏移量已做了相应更新。
  • 父进程和子进程各自执行不同的程序段:fork之后,父进程和子进程各自关闭它们不需使用的文件描述符,这样就不会干扰对方使用的文件描述符。这种方法是网络服务器进程经常使用的

子进程还继承父进程的属性还有:

  • 实际用户ID、控制终端
  • 设置用户ID标志和设置组ID标志
  • 当前工作目录、环境、根目录、文件屏蔽字
  • 对任一打开文件描述符的执行时关闭(close-on-exec)标志
  • 连接的共享存储段、存储映像

3) fork使用模式

fork有两种用法,复制自己从而进行不同的操作,或是复制自己执行不同的程序。

  •     父进程和子进程同时执行不同的代码段:如在网络服务进程中,父进程等待客户的服务请求,父进程调用fork使子进程处理此请求,父进程则继续等待下一个服务请求。
  •     父进程和子进程要执行一个不同的程序:如在shell中,子进程从fork返回之后立即调用exec

2.1.2 vfork函数

 

    vfork函数用于创建一个新进程,而该新进程的目的是exec一个新程序。所以vfork与fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec(或exit),于是也就不会引用该地址空间。vfork保证子进程先运行,并在子进程调用exec或exit之前,它在父进程的空间中运行。

2.2 进程的终止

    在1.1小节中,介绍了进程5种正常终止和3种异常终止的方式。其中不管进程如何终止,最后都会执行内核中的同一段代码,来为相应进程关闭所有打开描述符,及释放它所使用的存储器等。

    子进程的通过如下方式来通知父进程自己是如何终止的:

  • 对于3个终止函数(exit、_exit、_Exit),是将退出状态作为参数传送给函数。如exit(0)。
  • 在异常终止情况下,内核(不是进程本身)产生一个指示器异常终止原因的终止状态(termination status)。

    在任意一种情况下,该终止进程的父进程都能用wait或waitpid函数取得其终止状态。

注意:

  • 一个已经终止、但其父进程尚未对其进行善后处理(获取终止子进程的有关信息,释放它仍占用的资源)的进程被称为僵死进程
  • 如果父进程在子进程之前终止,那么此已终止父进程的所有子进程,将会改变它们的父进程为init进程,称这些子进程被init收养

2.3 监控子进程

 

    当一个进程正常或异常终止时,内核就向其父进程发送SIGCHLD信号因为子进程终止时一个异步事件(这可以在父进程运行的任何时候发生),所以这种信号也是内核向父进程发的异步通知。其中父进程可以通过调用wait或waitpid来获得子进程的终止状态。

其中在调用上述两函数之后,可能会发生如下的情况

  • 如果其所有子进程都还在运行,则阻塞。
  • 如果一个子进程已终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。
  • 如果它没有任何子进程,则立即出错返回。
pid_t wait( int *statloc); 
pid_t waitpid(pid_t pid,  int *statloc,  int options); 
    两个函数返回值:若成功,返回进程ID,statloc返回终止状态;若出错,返回0或- 1

 这两个函数的区别是:

  • 在一个子进程终止前,wait使其调用者阻塞,而waitpid有一选项(ANOHANG),可使调用者不阻塞。
  • waitpid并不等待在其调用之后第一个终止子进程,它由若干个选项,可以控制它所等待的进程。

    由于UNIX信号一般是不排队的,所以当有多个子进程同时终止,那么wait和waitpid只能获得一个子进程的终止状态,为了防止余下的子进程得不到处理(成为僵死进程),那么只能使用waitpid监控了

 

  while( (pid=waitpid(- 1,&stat, WNOHANG) )> 0// 一直等待子进程终止,直到没有已终止子进程,则跳出循环 
        printf( " child %d teminated\n ",pid); 

      上述代码如果将waitpid改为wait,也可以获得所有子进程的终止状态,但父进程(当前进程)将进入永远的阻塞状态,因为如果没有子进程,那么wait将会阻塞,不能返回。

2.4 程序的执行

 

    Linux可以通过一系列exec函数(7个)来执行另一个程序,被加载的新程序将替换某一进程的内存空间,旧进程的栈、数据以及堆段会被新程序的相应部件所替换,并且新程序会从main()函数出开始执行。调用exec函数之后,进程的ID仍保持不变。

2.4.1 exec函数 

int execl( const  char *pathname,  const  char *arg0,… ); 
int execv( const  char *pathname,  char* const argv[]); 
int execle( const  char *pathname,  const  char* arg0, …); 
int execve( const  char *pathname,  charconst argv[],  char * const envp[]); 

int execlp( const  char *filename,  const  char *arg0,…); 
int execvp( const  char *filename,  charconst argv[]); 
 
int fexecve( int fd,  char * const argv[],  char * const envp[]); 
                                                           7个函数返回值:若出错,返回- 1,若成功,不返回
 
这些函数之间的第一个区别是:

    前4个函数取路径名作为参数,后两个取文件名作为参数(p),最后一个取文件描述符作为参数(f)

第二个区别是:

    是参数表传递的不同,l表示列表listv表示矢量vector

第三个区别是

    是否传递环境表e

    在Linux中只有execve是内核的系统调用,另外6个则只是库函数,6个库函数都需调用该系统调用,如下图所示:

Linux多线程总结_第3张图片

 

2.4.1 属性的继承

1) 基本属性

    在执行exec之后,除了进程ID不变外,新程序还继承了如下属性:

  • 进程ID和父进程ID
  • 实际用户ID和实际组ID
  • 附属组ID、进程组ID、会话ID
  • 控制终端
  • 当前工作目录、跟目录
  • 文件模式创建屏蔽字、进程信号屏蔽字
  • 文件锁
  • 未处理信号
  • 资源限制

2) 文件描述符

    其中打开文件的处理与每个描述符的执行时关闭(close-on-exec)标志值有关,即FD_CLOEXEC,若对文件描述符设置了此标志,则在执行exec时关闭该描述符;否则该描述符仍打开。

3) 设置用户ID和设置组ID

    注意,在exec前后实际用户ID和实际组ID保持不变,但如果对pathname所指定的程序文件设置了set_user-ID(set-group-ID)权限位,那么系统调用会在执行此文件时将进程的有效用户(组)ID置为程序文件的属主(组)ID。

2.4.3 system函数

 

程序可通过调用system函数来执行任意的shell命令。其函数原型如下: 

#include <stdlib.h> 
int system( const  char* cmdstring);

      函数system()的实现中调用了fork、exec和waitpid等函数,其创建一个子进程来运行shell,从而执行了命令cmdstring。如system("ls | less")

 

3 信号

 

    信号是事件发生时对进程的一种通知方式。事件可以是硬件异常(如除以0)、软件条件(如alarm定时器超时)、终端产生的信号或调用kill函数。

  • 信号产生(generation):是指事件发生的动作,信号产生的时间就是事件发生的时刻;
  • 信号递送(delivery):是指这样一个过程,即当信号产生时,内核会在进程表中以某种形式设置一个标志。
  • 信号未决(pending):是指在信号产生和递送之间的时间间隔内。

3.1 信号的传递

 

    进程可以选用"阻塞信号递送"。如果为进程产生了一个阻塞的信号,而且对该信号的动作是系统默认动作捕捉该信号,则该进程将此信号保持为未决状态,直到该进程对此信号解除了阻塞,或者将对此信号的动作更改为忽略

内核将信号传递给进程有如下几种可能

  • 如果内核接下来要调度该进程运行,而等待信号会马上传递给进程。
  • 如果进程正在运行,则会立即传递信号给进程。
  • 如果所产生的信号是进程的阻塞信号之一,那么信号将保持等待状态,直至对该信号解除阻塞(从信号掩码移除)。

注意:

  1. 由于信号是不进行排队处理的,所以如果在解除阻塞某种信号之前,该信号发生了多次,那么在解除之后该信号只会被递送给进程一次。
  2. 由于不同信号的处理是无序的,所以在解除阻塞之前产生了多个信号,那么解除之后进程接收的顺序不一定与产生的顺序一致。

3.2 信号的处理方式

 

在某个信号出现时,可以告诉内核按下列3种方式之一进行处理:

  1. 忽略此信号

    大多数可以这样处理,但有两种信号不能被忽略,是SIGKILL和SIGSTOP。

  2. 捕捉信号

    用户可以通知内核在某种信号发生时,调用一个用户函数。但SIGKILL和SIGSTOP不能被捕捉。

  3. 执行系统默认动作

    每一种信号都有默认动作,基本都是终止该进程。

     

3.3 接收信号:signal和sigaction

signal和sigaction函数都是设定接收信号的处理方式,若未对信号重新设置,则进程按默认方式处理。

3.3.1 signal

    UNIX系统信号机制最简单的接口是signal函数:

void (*signal( int signo,  void (*func)( int) ) )( int); 
                                           返回值:若成功,返回以前的信号处理函数;若出错,返回SIG_ERR 

    signo参数是要处理的信号名,func的值可以是常量SIG_IGN,常量SIG_DEL或当接到信号后要调用的函数地址。即对应信号的三种处理方式。

  • SIG_IGN:忽略此信号
  • SIG_DEL:按系统默认动作
  • 函数地址:在信号发生时,调用该函数(信号处理程序);

 

在exec或fork后的信号处理方式;

1) 调用exec

    一个进程原先要捕捉的信号,当调用exec执行一个新程序后,这些被捕获信号的处理方式都被更改为按系统默认方式处理,因为信号捕捉函数的地址很可能在所执行的新程序文件中已无意义。

2)   fork进程

    当一个进程调用fork时,其子进程继承父进程的信号处理方式。因为子进程在开始时复制了父进程内存映象,所以信号捕捉函数的地址在子进程中是有意义的。

 

3.3.2 sigaction

 

    sigaction函数的功能是检测或修改与制定信号相关联的处理动作。

 

int sigaction( int signo,  const  struct sigaction* restrict act,  struct sigaction* restrict oact); 
返回值:若成功,返回0;若出错,返回- 1 

struct sigaction{ 
         void (*sa_hander)( int);                   // 处理函数,或SIG_IGN, SIG_DEL 
        sigset_t sa_mask;                         // 屏蔽字 
         int sa_flags;                             // 选项 
  void (*sa_sigaction)( int , siginfo_t*,  void*);  // 选择项 
  • signo是要检测或修改其具体动作的信号编号。
  • act指针非空,则要修改其动作。
  • 如果oact指针非空,则系统经由oact指针返回该信号的上一个动作。

使用方法:

    当更改信号动作时,如果sa_handler字段包含一个信号捕捉函数的地址(不是常量SIG_IGN或SIG_DFL),则sa_mask字段说明了一个信号集,在调用该信号捕捉函数之前,这一信号集要加到进程的信号屏蔽字中。仅当信号捕捉函数返回时再将进程的信号屏蔽字恢复为原先值。

 

3.4 发送信号:kill、raise和alarm

 

    kill函数将信号发送指定进程或进程组;raise函数是立即向进程自身发送信号;alarm函数是设置一个定时器,当超时后自身发送一个信号。

3.4.1 kill函数 

int kill(pid_t pid,  int signo); 
                             两个函数返回值:若成功,返回0;若出错,返回- 1
 kill的pid参数有以下4种不同的情况:

  • pid>0将信号发送给进程IDpid的进程。
  • pid==0将信号发送给与发送进程属于同一进程组的所有进程。
  • pid<0将信号发送给进程组ID等于pid绝对值。
  • pid==-1将信号发送给所有进程(有权限的进程)。

注意:将信号发生给其它进程需要权限

  • 超级用户:可将信号发送给任一进程;
  • 非超级用户:信号发生进程的实际用户ID或有效用户ID必须等于接收进程的实际用户ID或有效用户ID。

3.4.2 raise函数 

 

 

int raise( int signo); 
                                 两个函数返回值:若成功,返回0;若出错,返回- 1

     当进程调用raise函数,信号立即被传递(即,在raise函数返回调用者之前)。并且raise出错的唯一原因是signo无效。 

3.4.3 alarm函数 

#include <unistd.h> 
unsigned  int alam(unsigned  int seconds) 
                                  返回值:0或以前设置的闹钟时间的余留秒数

       使用alarm函数可以设置一个定时器(闹钟时间),在将来的某个时刻该定时器会超时。当定时器超时时,会产生SIGALRM信号。如果忽略或不捕捉此信号,则其默认动作是终止调用该alarm函数的进程。其中进程调用alam()函数后是不挂起的,仍然继续执行

3.5 挂起进程: sleep和pause

sleep和pause函数都能使调用进程挂起,但sleep在指定的时间超时或接收到信号时就能唤醒;而pause只能接收到信号才得以唤醒,否则将一直处于挂起状态。

3.5.1 sleep函数 

 

#include <unistd.h> 
int sleep( int seconds) 
                       返回值:0或未休眠完成的秒数

     此函数使调用进程被挂起知道满足以下两个条件之一:

  1. 已经过了seconds锁指定的墙上时钟时间;
  2. 调用进程捕捉到一个信号并从信号处理程序返回。

3.5.2 pause函数

#include <unistd.h> 
int pause( void
                       返回值:- 1,errno设置为EINTR 

         pause函数使得调用进程自己挂起,直至当前进程接收到某种信号,才得以唤醒;否则一直处于阻塞状态。

注意:

只有执行了一个信号处理程序并从其返回时,pause才返回。

 

3.6 信号屏蔽功能:sigprocmask、sigpending和sigsuspend

    内核会为每个进程维护一个信号掩码(即一个信号集),从而进程将屏蔽信号掩码中的信号集。若将被屏蔽的信号发送给进程,那么该信号的传递将被延后(只是被延后,不是被删除),直至该进程解除该信号,从而该进程仍能够接收到先前被屏蔽的信号。

关于进程的信号屏蔽,UNIX还提供了如下功能: 

l  sigprocmask

       调用函数sigprocmask可以检测更改,或同时进行检测和更改进程的信号屏蔽字

l  sigpending

       该函数返回正处于等待状态的信号集,即由于某个信号是屏蔽字段的类型之一,并且发送给了调用进程,那么将从set返回。

l  sigsuspend(sigset_t *sigmask)

       该函数的功能是将屏蔽字更改sigmask,并挂起调用进程;直至接收到sigmask之外的信号,才唤醒该进程,并将屏蔽字恢复为调用sigsuspend函数之前的屏蔽字该过程可分为3个步骤完成:首先设置指定屏障字;然后使进程挂起(类似pause功能);最后接收到除sigmask信号,并恢复之前的屏蔽字。

 

注意:

    屏蔽信号,并不是删除信号,当解除屏蔽信号时,被阻塞的信号仍能被进程接收。

4 进程通信

 

4.1 管道和FIFO

4.1.1 管道

管道有两种局限性:

  • 历史上,它们是半双工的,虽然某些系统提供全双工管道。
  • 管道只能在具有公共祖先的两个进程之间使用。

创建管道函数原型是: 

int pipe( int fd[ 2]); 
                  返回值:若成功,返回0,,若出错,返回- 1

 

    经由参数fd返回两个文件描述符,fd[0]为读而打开,fd[1]为写而打开,然后就能像使用普通文件描述符一样,进行读写了。

    单个进程中的观点几乎没有任何用处。通常,进程会先调用pipe,接着调用fork,从而创建从父进程到子进程的IPC通道。对于从父进程到子进程的管道,如父进程关闭管道的读端(fd[0]),子进程关闭写端(fd[1])。如下图所示:

Linux多线程总结_第4张图片

当管道的一端被关闭后,下列两条规则起作用:

  1. 当读(read)一个写端已被关闭的管道时,在所有数据都被读取后,read返回0,表示文件结束。
  2. 如果写(write)一个读端已被关闭的管道,则产生信号SIGPIPE。如果忽略该信号或者捕获该信号并从其处理程序返回,则write返回-1,errno设置为EPIPE。

 

如下程序:

 

 1 main(){ 
 2      int n,fd[ 2];    pid_t pid;      char line[ 100]; 
 3     pipe(fd); 
 4      if((pid=fork()< 0
 5          return
 6      else  if(pid> 0){ 
 7         close(fd[ 0]); 
 8         write(fd[ 1], " hello world\n ", 12);  // 向管道中写入数据
 9      } 
10      else
11         close(fd[ 1]); 
12         n = read(fd[ 0],line, 12);      // 从管道中读取数据 
13          write(STDOUT_FILENO, lien, n); // 输出终端 
14      } 
15 } 

4.1.2 FIFO

 

    FIFO也称为命名管道。与管道不同,通过FIFO不相关的进程也能交换数据。FIFO的使用方式是:先创建(mkfifo),然后使用open打开,接着就能使用I/O系统调用(如read()、write()和close())操作打开的文件描述符了。

 

int mkfifo( const  char* path, mode_t mode); 
                                             返回值:若成功,返回0;若出错,返回- 1
  • path参数:表示FIFO文件的保存路径,即mkfifo创建一个名为path的FIFO。
  • mode参数:指定了新FIFO的权限。

    与管道一样,FIFO也有一个写入端和读取端并且从管道中读取数据的顺序与写入的顺序是一样的。

其中需注意:

  • 打开一个FIFO以便读取数据(open() O_RDONLY标志)将会阻塞,直到另一个进程开大FIFO以写入数据(open() O_WRONLY标志)为止
  • 相应地,打开一个FIFO会同步读取进程和写入进程。如果一个FIFO的另一端已经打开(可能是因为一对进程已经打开了FIFO的两端),那么open()调用会立即成功

4.2 消息队列

 

    消息队列与FIFO类似,都是需要先创建一个标识,然后通过这个标识进行消息的发送和接收。

1) 创建或打开一个消息队列 

 

int msgget(ket_t key,  int msgflag); 
                                返回值:若成功,返回消息队列ID;若出错,返回- 1
  • key参数:是消息队列的一个标识,将这个标识作为消息队列的外部名;
  • msgflag参数:是消息队列的权限。

2) 发送消息

msgsnd功能是将新消息添加到队列尾端。 

 

int msgsnd( int msqid  const  void* ptr, size_t nbytes,  int flag); 
返回值:若陈工,返回0,若出错,返回- 1

3) 接收消息

    msgrcv用于从队列中取消息,可以按先进先出次序取消息,也可以按消息的类型字段取消息。 

 

int msgrcv( int msqid,  void* ptr, size_t nbytes,  long type,  int flag); 
返回值:若成功,返回消息数据部分的长度;若出错,返回- 1

4.3 信号量

 

    信号量不是用来在进程间传输数据的,相反,它是用来同步进程的动作。信号量的一个常见用途是同步对一块共享内存的访问以防止出现一个进程在访问共享内存的同时另一个进程更新这块内存的情况。

    

为了获得共享资源,进程需要执行下列操作:

  1. 测试控制该资源的信号量。
  2. 若此信号量的值为正,则进程可以使用该资源在这种情况下,进程会将信号量值减1,表示它使用了一个资源单位。
  3. 否则,若此信号量的值为0,则进程进入休眠状态直至信号量值大于0.进程被唤醒后,它返回至步骤1)。  

使用信号量的常规步骤如下:

  1. 使用semget() 创建或打开一个信号量集。
  2. 使用semctl() SETVAL或SETALL操作初始化集合中的信号量。(只有一个进程需要完成这个任务)
  3. 使用semop() 操作信号量值。使用信号量的进程通常会使用这些操作来表示一种共享资源获取和释放
  4. 当所有进程都不在需要使用信号量集之后使用semctl() IPC_RMID操作删除这个集合。(只有一个进程需要完成这个任务)

4.4 共享内存

    共享存储允许多个进程共享一个给定的存储区。因为数据不需要在客户进程和服务器进程之间复制,所以这是最快的一种IPC。为了防止多个进程同时访问共享存储区,通常使用信号量同步共享存储区的访问(也可使用记录锁或互斥量)。

    共享存储与内存映射不同之处在于,前者没有相关的文件,共享存储段是内存的匿名段。

为使用一个共享内存段通常需要执行下面的步骤:

  1. 调用shmget()创建一个新共享内存段或取得一个既有共享内存段的标识符(有其它进程创建的共享内存段)。这个调用将返回后续调用中需要用到的共享内存标识符。
  2. 使用shmat()来附上共享内存段,即使该段成为调用进程的虚拟内存的一部分。
  3. 此刻在程序中可以像对待其它可用内存那样对待这个共享内存段。为引用这块共享内存,程序需要使用由shmat()调用返回的addr值,它是一个执行进程的虚拟地址空间中该共享内存段的起点的指针。
  4. 调用shmdt()来分离共享内存段。这个调用之后,进程就无法在引用这块共享内存了。这一步是可选的,并且在进程终止时会自动完成这一步。
  5. 调用shmctl()来删除共享内存段。是由当当前所有附加内存段的进程都与之分离之后内存段才会被销毁。只有一个进程需要执行这一步。

5 线程

 

5.1 基本概念

1) 什么是线程

线程是一个进程内部的一个控制序列。所有的进程都至少有一个执行线程。

一个进程的所有信息对该进程的所有线程都是共享的,包括可执行程序的代码,程序的全局内存和堆内存、栈以及文件描述符,即同一个进程的线程同处于一个地址空间,彼此能够互相访问栈地址(只要可见)。但不同的进程拥有完成不相干的地址空间。

如:

 

 1 #include <stdio.h> 
 2 #include <pthread.h> 
 3 #include <unistd.h> 
 4  int main() 
 5 { 
 6      int num= 100, a; 
 7      int *p = &num; 
 8     pid_t pid; 
 9  
10     printf( " before fork num:%d\n ",num); 
11      if((pid=fork())== 0
12     { 
13         *p =  200// 修改了子进程的p地址所指的值 
14          printf( " %d@%d:%d\n ",getppid(),getpid(),num); 
15     } else  if(pid> 0
16     {     
17         wait(&a); 
18         printf( " %d@%d:%d\n ",getppid(),getpid(),num); 
19     } 
20 } 
21 
22 输出:即父子进程地址P的值相同,但彼此不相关 
23     before fork num: 100 
24     30754@ 30755: 200 
25     18163@ 30754: 100 

 

2) 线程标识

每个线程也有一个线程ID。进程ID在整个系统中是唯一的,但线程ID不同,线程ID只有在它所属的进程上下文中才有意义。

 

#include<pthread.h> 
int pthread_equal(pthread_t tid1, pthread_t tid2) 
                                             返回值:若相等,返回非0数值:否则,返回0 
pthread_t pthread_self( void
                                            返回值:调用线程的线程ID 

 

5.2 线程创建

在POSIX线程(pthread)情况下,程序开始运行时,它也是以进程中的个控制线程(主线程)启动的。 

 

int pthread_create(pthread_t *tidp, pthread_atti_t *attr,  void *(*start_rtn)( void*),  void* arg) 
返回值:若成功,返回0;否则,返回出错编号

       当创建成功返回时,新创建线程的线程ID会被设置成tidp指向的内存单元;新创建的线程从start_rtn(称为启动例程)函数的地址开始运行,该函数只有一个不类型指针参数art。

5.3 线程终止

      如果进程中的任意线程调用了exit、_Exit或者_exit,那么整个进程就会终止,其中主线程退出也会使整个进程终止。与此类似,如果信号的默认动作是终止进程,那么,发送到线程的信号就会终止整个进程。

单个线程可以通过3种方式退出,因此可以在不终止整个进程的情况下,停止它的控制流:

  1. 线程可以简单地从启动例程中返回,返回值是线程的退出码;
  2. 线程可以被同一进程中的其他线程取消;
  3. 线程调用pthread_exit。

5.3.1 线程退出

#include <pthread.h> 
void pthread_exit( void* rval_ptr); 

    pthread_exit是线程退出函数,rval_ptr是推出的状态码;

5.3.2 线程等待

#include <pthread.h> 
int pthread_join(pthread_t thread,  void** rval_ptr) 

     调用pthead_join线程将获得指定线程的退出状态;当线程调用了pthread_join函数则将一直阻塞,直到指定的线程调用pthread_exit、从启动例程中返回或者被取消

5.3.3 线程取消

 

#include <pthread.h> 
void pthread_cancel(pthread_t tid); 

   线程可以通过调用pthrad_cancel函数来请求取消同一个进程中其他线程。在默认情况下,pthread_cancel函数会使得tid标识的线程的行为表现为如同调用了参数为PTHREAD_CANCEL的pthread_exit函数但是,线程可以忽略取消或者控制如何被取消。

5.3.4 线程清理处理程序

线程内存空间中存在着一个特殊栈,此栈用来记录清理函数的地址。该栈由如下两个函数来完成入栈和出栈操作。它们的执行顺序与它们的注册时相反。

#include <pthread.h> 
void pthread_cleanup_push( void (*rtn)( void*),  void * arg); 
void pthread_cleanup_pop( int execute); 

    当线程执行以下动作时清理函数rtn将被调用执行,其中arg是传递的函数。

  • 调用pthread_exit时
  •     响应取消请求时
  •     用非零execute参数调用pthread_cleanup_pop时。

5.4 线程同步

 

术语临界区(critical section)是指访问某一共享资源的代码片段,并且这段代码的执行应为原子操作,亦即,同时访问同一共享资源的其他线程不应中断该片段的执行。

线程有如下的5种基本的同步机制。

5.4.1 互斥量(pthread_mutex_t)

互斥量(mutex)是线程同步的一把锁,该锁有两个状态:已锁定未锁定。互斥量保证了同一个时间只有一个线程(获得锁的进程)访问临界区,并且该锁只能由获得锁的线程主动释放。

1) 初始化与销毁

互斥量的数据类型是pthread_mutex_t在使用互斥量之前,首先必须对它进行初始化,有如下两种情况:

  • 静态分配的互斥量:将互斥量赋值为常量PTHREAD_MUTEX_INITIALIZER;
  • 动态分配的互斥量:通过调用pthread_mutex_init函数进行初始化,

如果是动态分配的互斥量 (例如,通过调用malloc函数或全局变量),在释放内存前需要调用pthread_mutex_destroy。

 

#include <pthread.h> 
int pthread_mutex_init(pthread_mutex_t *mutex, pthread_mutexattr_t *attr); 
int pthread_mutex_destroy(pthread_mutex_t *mutex);

    要默认的属性初始化互斥量,只需把attr设为NULL。

2) 加锁与解锁

对互斥量进行加锁,需要调用pthread_mutex_lock,如果互斥量已经上锁了,调用线程将阻塞知道互斥量被解锁;对互斥量解锁,需要调用pthread_mutex_unlock。 

 

#include <pthread.h> 
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_trylock尝试对互斥量进行加锁调用pthread_mutex_trylock有两种情况:

  • 互斥量处于未锁住状态:则直接返回0,那么成功锁住互斥量,不会出现阻塞;
  • 互斥量处于已锁住状态:则返回EBUSY,不能锁住互斥量。

5.4.2 条件变量(pthread_cond_t)

 

互斥量为防止多个线程同时访问同一个共享量,而条件变量允许一个线程就某个共享变量(或其他共享资源)的状态变化通知其他线程,并让其他线程等待(阻塞于)这一通知。条件变量总是结合互斥量一起使用,条件变量就共享变量的状态改变发出通知,而互斥量则提供对该共享变量访问的互斥。

1) 初始化与销毁

与互斥量类似,对条件变量的初始化分为静态类型和动态类型,动态类型的初始化和销毁函数为:

#include <pthread.h> 
int pthread_cond_init(pthread_cond_t *cond, pthread_condattr *attr) 
int pthread_cond_destroy(pthread_cond_t *cond) 

 

2) 等待通知

等待其他线程的发送的条件变量,可以使用如下两个函数,第二个只是增加了等待时间,其余功能都一样。

 

#include <pthread.h> 
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex) 
int pthread_cond_timewait(pthread_cond_t *cond, pthread_mutex_t *mutex,  struct timespec *tsptr); 

调用线程将锁住的mutex信号量传递给pthread_cond_wait函数,然后该线程自动进入等待状态(阻塞),并对此互斥量解锁。从pthread_cond_wait唤醒必须同时具备如下条件:

  • 其它线程对cond条件变量发送了唤醒操作:pthread_cond_signal或pthread_cond_broadcast。
  • mutex没有被锁住

3) 发送通知

 

#include <pthread.h> 
int pthread_cond_signal(pthread_cond_t *cond) 
int pthread_cond_broadcast(pthread_cond_t *cond) 

     这两个函数都可以用于唤醒阻塞于cond条件变量的线程,pthread_cond_signal函数至少能唤醒一个等待该条件变量的线程,而pthread_cond_broadcast函数则能唤醒等待该条件变量的所有线程。

注意:

  1. pthread_cond_signal和pthread_cond_broadcast只是通知的cond条件变量的线程,而未对mutex信号量进行修改,需在调用上述两个唤醒函数之前手动进行解锁(pthread_mutex_unlock)。
  2. 若调用了pthread_cond_broadcast唤醒所有等待线程,则只有一个线程能从阻塞状态唤醒,因为只有一个线程能获得mutex互斥量,其它线程只能等待获得mutex锁。
  3. 应该把调用pthread_cond_wait函数放在while之中,判断条件是"共享资源不可用",即当判断了共享资源不可用,应继续调用pthread_cond_wait等待。

5.4.3 读写锁(pthread_rwlock_t)

 

       与只有2种状态的互斥量不同,读写锁有3种状态:模式下加锁状态、模式下加锁状态、不加锁状态。其中一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。

在读写锁的3种状态下,进行读或写申请给出的响应各不相同:

  • 写加锁状态:所有试图对这个锁进行加锁的线程都会被阻塞
  • 读加锁状态:所有试图以读模式对这个锁进行加锁都可以访问,但是任何以写模式对此锁进行加锁的线程都会阻塞

1) 初始化与销毁

与互斥量相比,读写锁在使用之前必须初始化在释放内存之间必须销毁。 

 

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

 

2) 加锁与解锁

对读写锁进行读加锁、写加锁,以及解锁的3个函数如下: 

#include <pthread.h> 
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock) 
int pthread_rwlock_wdlock(pthread_rwlock_t *rwlock) 
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock) 
返回值:若成功,返回0;否则,返回错误编号

 

Single UNIX Specification还定义了读写锁原语的条件版本 

 

#include <pthread.h> 
int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock) 
int pthread_rwlock_trywdlock(pthread_rwlock_t *rwlock) 
返回值:若成功,返回0;否则,返回错误编号

 

5.4.4 自旋锁(pthread_spinlock_t)

 

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

自选锁通常作为底层原语用于实现其他类型的锁。但是,在用户层,自旋锁并不是非常有用,除非运行在不允许抢占的实时调度类中。所以这里不过多叙述.

5.4.5 屏障(pthread_barrier_t)

屏障是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有合作线程都到达某一点,然后从该点继续执行。

1) 初始化与销毁

与读写锁类似,屏障不分静态和动态变量,所有屏障类型变量都需通过pthread_barrier_init进行初始化。

#include <pthread.h> 
int pthread_barrier_init(pthread_barrier_t *barrier, pthread_barrierattr_t *attr, unsigned  int count) 
int pthread_barrier_destroy(pthread_barrier_t *barrier); 

 初始化屏障时,可以使用count参数指定,在允许所有线程继续运行之前,必须到达屏障的线程数目。  

2)等待

可以使用pthread_barrier_wait函数来表明,线程已完成工作,准备等所有其他线程赶上来。

#include <pthread.h> 
int pthread_barrier_wait(pthread_barrier_t *barrier) 
                                         返回值:若成功,返回0或者PTHREAD_BARRTER_SERIAL_THREAD;否则,返回错误编号

调用pthread_barrier_wait的线程在屏障计数(调用pthread_barrier_init时设定)未满足条件时,会进入休眠状态。如果该线程是最后一个调用pthread_barrier_wait的线程,就满足了屏障计数,所有线程都被唤醒。

 

 

 

6 附录

 

附录1:fork和exec函数对进程属性的影响

当进程执行fork和exec会促使子进程继承父进程很多属性,下表对fork和exec执行后对进程属性的影响进行的对比和总结。

进程属性

exec()

fork()

影响属性的接口;额外说明

进程地址空间

文本段

û

共享

子进程与父进程共享文本段

栈段

û

ü

函数入口/出口:alloca()、longjmp()、siglongjmp()

数据段和堆段

见注释

ü

Brk()、sbrk()

环境变量

û

ü

putenv()、setenv();直接修改environ。execle()和execve()队对其改进,其它exec()调用则会加以保护。

内存映射

û

ü;见注释

mmap()、munmap()。跨越fork()进程,映射的MAP_NORESERVE标志得以继承。带有madvise(MADV_DONTFORK)标志的映射则不会跨fork()继承

内存锁

û

û

mlock()、munlock()

进程标识符和凭证

进程ID

ü

û

 

父进程ID

ü

û

 

进程组ID

ü

ü

setpgid()

回话ID

ü

ü

setsid()

实际ID

ü

ü

setuid()、setgid(),以及相关调用

有效和保存设置ID

见注释

ü

Setuid()、setgid(),以及相关调用

补充组ID

ü

ü

setgroups()、initgroups()

文件、文件IO和目录

打开文件描述符

见注释

ü

open(),close()、dup()、pipe()、socket()等。文件描述符在跨越exec()调用的过程中得以保存,除非对其设置了执行时关闭(close-on-exec)标志。父、子进程中的描述符指向相同的打开文件描述符

执行时关闭(close-on-exec)标志

ü (如果关闭)

ü

fcntl(F_SETFD)

文件偏移

ü

共用

lseek()、read()、write()、readv()、writev()。父、子进程共享文件偏移

打开文件状态标志

ü

共用

Open()、fcntl(F_SETFL)。父子进程共享打开文件状态标志

异步IO操作

见注释

û

aio_read()、aio_write()以及相关调用。调用exec()期间,会取消尚未完成的操作

目录流

û

ü (见注释)

opendir()、readdir()。SUSv3规定,子级才能获得父进程目录流的一份副本,不过这些副本可以(也可以不)共享目录流的位置。Linux系统不共享目录流的位置

文件系统

当前工作目录

ü

ü

chdir()

根目录

ü

ü

chroot()

文件模型创建掩码

ü

ü

umask()

信号

信号设置

见注释

ü

Signal()、sigaction()。将处置设置成默认或忽略的信号在执行exec()期间保持不变;已捕获的信号会恢复为默认处置

信号掩码

ü

ü

信号传递;sigprocmask()、sigaction()

挂起(pending)信号集合

ü

û

信号传递;raise()、kill()、sigqueue()

备选信号栈

û

ü

sigaltstack()

定时器

间隔定时器

ü

û

setitimer()

由alarm()设置的定时器

ü

û

alarm()

POSIX定时器

û

û

timer_create()及其相关调用

POSIX线程

线程

û

见注释

fork()调用期间,子进程只会复制调用线程

线程可撤销状态与类型

û

ü

Exec()之后,将可撤销类型和状态分别重置为PTHREAD_CANCEL_ENABLE和PTHREAD_CANCEL_DEFERRED

互斥量与条件变量

û

ü

关于调用fork()期间对互斥量

优先级与调度

nice值

ü

ü

nice()、setpriority()

调度策略及优先级

ü

ü

sched_setcheduler()、sched_setparam()

资源与CPU时间

资源限制

ü

û

setrlimit()

进程和子进程的CPU时间

ü

û

由times()返回

资源使用量

ü

û

由getrusage()返回

进程间通信

System V共享内存段

û

ü

shmat()、shemdt()

POSIX共享内存段

û

ü

shm_open()及相关调用

POSIX消息队列

û

ü

mq_open()及相关调用。父子进程的描述符都指向同一打开消息队列描述。子进程并不继承父进程的消息通知注册消息

POSIX命名信号量

û

共用

sem_open()及其相关调用。子进程与父进程共享对相同信号量的引用

POSIX未命名信号量

û

见注释

sem_init()及其相关调用。如果信号量位于共享内存区域,那么子进程与父进程共享信号量;否则,子进程拥有属于自己的信号量拷贝。

System V信号量调整

ü

û

 

文件锁

ü

见注释

flock()。子进程自父进程处继承对同一锁的引用

记录锁

见注释

û

fcntl(F_SETLK)。除非将指代文件的文件描述符标记为执行时关闭,否则会跨越exec()对锁加以保护

杂项

地区设置

û

ü

setlocale()。作为C运行时初始化的一部分,执行新程序后会调用setlocale(LC_ALL,"C")的等效函数

浮点环境

û

ü

运行新程序时,将浮点环境状态重置为默认值,参考fenv

控制终端

ü

ü

 

退出处理器程序

û

ü

atexit()、on_exit()

Linux特有

文件系统ID

见注释

ü

setfsuid()、setfsgid()。一旦相应的有效ID发生变化,那么这些ID也会随之改变

timeerfd定时器

ü

见注释

timerfd_create(),子进程继承的文件描述符与父进程指向相同的定时器

能力

见注释

ü

capset()。

功能外延集合

ü

ü

 

能力安全位(securebits)标志

见注释

ü

执行exec()期间,会保全所有的安全位标志,SECBIT_KEEP_CAPS除外,总是会清除该标志

CPU黏性(affinity)

ü

ü

sched_setaffinity()

SCHED_RESET_ON_FORK

ü

ü

 

允许的CPU

ü

ü

 

允许的内存节点

ü

ü

 

内存策略

ü

ü

 

文件租约

ü

ü

Fcntl(F_SETLEASE)。子进程从父进程处继承对相同租约的引用

目录变更通知

ü

û

dnotify API,通过fcntl(F_NOTIFY)来实现支持

prctl(PR_SET_DUMPABLE)

见注释

ü

exec()执行期间会设置PR_SET_DUMPABLE标志,自行设置用户或组ID程序的情况除外,此时将清除该标志

prctl(PR_SET_PDEATHIG)

ü

û

 

prctl(PR_SET_NAME)

û

ü

 

oom_adj

ü

ü

 

coredump_filter

ü

ü

 

你可能感兴趣的:(Linux多线程总结)