C/C++ Linux并发编程

第六章 进程控制

1. 进程和程序

程序时存放在存储介质上的可执行文件,而进程是程序执行的过程。程序是静态的,进程是动态的。进程的状态包括创建、调度、消亡。

在Linux操作系统中,操作系统通过进程去完成一个一个的任务,进程是管理事务的基本单元。

进程拥有自即独立的处理环境(如:当前需要用到哪些环境变量,程序的运行目录在哪,当前是哪个用户在运行此程序等)和系统资源(如:CPU占用率、存储器、IO设备、数据、程序)。

2. 并行和并发(了解)

并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。

并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,使得宏观上具有多个进程同时执行的效果。

3. MMU(了解)

MMU是Memory Management Unit的缩写,中文叫内存管理单元,是CPU用来管理虚拟地址(逻辑地址)到物理地址的映射。

4. 进程控制块PCB

进程在运行时,内核为每个进程分配一个PCB,维护进程相关的信息。Linux内核中是task_struct结构体。

其内部成员很多,需要了解以下:

  1. 进程id
  2. 进程状态,有就绪、运行、挂起、停止等状态
  3. 进程切换时需要保存和恢复的一些寄存器
  4. 描述虚拟地址的信息
  5. 描述控制终端的信息
  6. 当前工作目录(cwd)
  7. umask掩码(权限掩码)
  8. 文件描述符,包含很多指向file结构体的指针
  9. 和信号相关的信息
  10. 用户id和组id
  11. 会话(session)和进程组
  12. 进程可以使用的资源上限(resource limit)
5. 进程的状态(重点)

三态模型:运行态、就绪态、阻塞态

五态模型:新建态、终止态、运行态、就绪态、阻塞态

僵尸态(僵尸进程):进程终止,但仍占用资源

6. 进程相关命令(重点)

i)ps

ps命令用于查看进程的详细状况

-a

显示终端上所有进程,包括其他用户的进程

-u

显示进程的详细状态

-x

显示没有控制终端的进程

-w

显示加宽,以便显示更多信息

-r

只显示正在运行的进程

ps -aux

ii)top

top命令用来动态显示运行中的进程

iii)kill

kill命令指定进程号的进程,需要配合ps使用。

使用格式:kill [-signal] pid

信号值从0到15,其中9为绝对终止。

iiii)killall

通过进程名字杀死进程

7. 进程号和相关函数

每个进程都由一个进程号来标识,其类型为pid_t(整型),范围是0~32767。

进程号(PID):标识一个进程的非负整数

父进程号(PPID):任何一个进程都是由另一个进程创建,该进程被称为创建进程的父进程。

进程组号(PGID):进程组是一个或多个进程的集合,进程组可以接收同一终端的各种信号。

  • getpid函数​​​​​​​

  • getppid函数

获取父进程号

  • getpgid函数

获取进程组号

8. 进程的创建

系统允许一个进程创建新进程,新进程即为子进程,子进程还可以创建新的子进程,形成进程树结构。

#include <sys/types.h>
#include <unistd.h>

pid_t fork():
/*
功能:
	用于从一个已存在的进程中创建一个新进程,新进程为子进程,原进程为父进程
返回值:
	成功:子进程中返回0,父进程中返回子进程ID。
    失败:-1
    失败的主要原因:
    1)进程数达到上限,errno为EAGAIN
    2)系统内存不足,errno为ENOMEM
*/

示例:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main(){
    fork();
    printf("hello world!\n");
    return 0;
}
//此时运行这个程序会打印两行hello world.
9. 父子进程关系

使用fork()函数得到的子进程是父进程的一个复制品,子进程所独有的只有它的进程号,计时器等。

写时拷贝(copy-on-write)。父子进程都共享一个地址空间,只有在需要写入时才会复制地址空间,从而使各个进程拥有各自的地址空间。

i)区分父子进程

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

int main(){
    pid_t pid = -1;
    //创建一个子进程
    pid = fork();
    if(pid==0){
        //子进程
        printf("hello child. pid:%D ppid:%d\n",getpid(),getppid());
        exit(0);
    }
    else{
        //父进程
        printf("hello base. pid:%d cpid:%s\n",getpid(),pid);
    }
    return 0;
}

ii)父子进程地址空间(重要)

读时共享,写时拷贝。

iii)父子进程堆空间

valgrind查看文件内存使用情况

10. GDB调试多进程

使用gdb调试的时候,gdb只能跟踪一个进程。可用在fork函数调用前,通过指令设置gdb调试工具跟踪父进程或者子进程。默认跟踪父进程。

set follow-fork-mode child
set follow-fork-mode parent
11. 进程退出函数

exit()是标准库函数,_exit()是系统调用。

12. 等待子进程退出函数

在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件、占用的内存等。但是仍然为其保留一定的信息,这些信息主要指PCB的信息(包括进程号、退出状态、运行时间等)。

父进程可以通过调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程。

wait会阻塞;

waitpid可用设置不阻塞,还可以指定等待哪个子进程。

一次只能清理一个子进程,要清理多个,就要循环调用。

  • wait函数

  • waitpid函数

13. 孤儿进程

父进程运行结束,但子进程还在运行,此时的子进程就是孤儿进程(Orphan Process)。孤儿进程没有危害,因为内核会把孤儿进程的父进程设置为init进程(系统启动时创建的一个进程),而init会循环地wait()。

14. 僵尸进程

进程终止,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸进程(Zombie Process)。如果进程不调用wait()或waitpid()的话,那么保留的信息就不会被释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的。所以僵尸进程是有害的,应该尽量避免。

15. execlp进程替换(exec函数族)

在Windows平台下,我们通过双击运行可执行程序,让可执行程序成为一个进程,而在Linux平台下,我们通过 ./ 运行。而在进程的内部想要启动一个外部程序,就需要用到exec()函数族。

#include <unistd.h>
extern char **environ;

int execl(const char *path,const char *arg,...,NULL);
int execlp(const char *file,const char *arg,...,NULL);

int execle(const char *path,const char *arg,...,NULL);
int execv(const char *path,char *const argv[])
int execvp(const char *file,char *const argv[]);
int execvpe(const char *file,char *const argv[],char *const envp[]);
int execve(const char *filename,char *const argv[],char *const envp[]);

进程调用一种exec函数时,该进程完全由新程序替换。相关属性(如进程ID、当前工作目录......都不会变)。

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

int main(){
	printf("Hello");
  //arg0一般是可执行文件名,最后一个必须是NULL
  //此处等价于执行ls -l /home
  execlp("ls","ls","-l","/home",NULL);
  printf("Bye");//不会输出Bye
  return 0;
}

execl()函数

第一个参数是可执行文件的路径;第二个参数一般是可执行文件的名字;中间参数就是可执行文件的参数;最后是NULL。

第七章 进程间通信

1. 进程间通信概念

进程间通信的目的:1. 数据传输,2. 通知事件(如进程终止时通知父进程),3. 资源共享(需要内核提供互斥和同步机制),4. 进程控制。

2. 无名管道(pipe)

管道也叫无名管道,它是UNIX系统IPC(进程间通信)的最古老形式,所有的UNIX系统都支持这种通信方式。

2.1. 特点

2.2. pipe函数

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

//用于创建无名管道
int main(){
    int fds[2];
    int ret = -1;
    //创建一个无名管道
    ret = pipe(fds);
    if(ret=-1){
        perror("pipe");
        return 1;
    }
    //fds[0]用于读,fds[1]用于写
    printf("fds[0]:%d,fds[1]:%d",fds[0],fds[1]);
	//关闭文件描述符
    close(fds[0]);
    close(fds[1]);
    return 0;
}
2.3. 父子进程通过无名管道通信
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define SIZE 64
//父子进程使用无名管道进行通信
int main(){
    pid_t pid = -1;
    int ret = -1;
    int fds[2];
    char buf[SIZE];
    //fork()之前创建管道,这样子进程才会连接到读端和写端(继承文件描述符)
    //此处,父进程写管道,子进程读管道

	//1.创建无名管道
    ret = pipe(fds);
    if(ret==-1){
        perror("pipe");
        return 1;
    }
    //2.创建子进程
    pid = fork();
    if(pid==-1){
        perror("fork");
        return 1;
    }
    //子进程 读
    if(pid==0){
        //关闭写端
        close(fds[1]);
        
        memset(buf,0,SIZE);
        ret = read(fds[0],buf,SIZE);
    	if(ret < 0){
            perror("read");
            exit(-1);
        }
        printf("child process buf: %s\n",buf);
        //读结束,关闭读端
        close(fds[0]);
        exit(0);
    }
    //父进程 写
    //关闭读端
    close(fds[0]);

    ret = write(fds[1],"ABCDEFGHIJK",10);
    if(ret<0){
        perror("write");
        return 1;
    }
	printf("parent process write len: %d/n",ret);
    //关闭写端
    close(fds[1]);
        
    return 0;
}
2.4. 管道的读写特点

四种情况:

第一种:写端没有关闭,管道中没有数据,读阻塞;写端没有关闭,管道有数据,此时读进程会将数据读出,下一次读就没有数据就会阻塞。

第二种:所有写端关闭,读进程读取全部内容,最后返回0。

第三种:所有读端没有关闭,管道写满,写会阻塞。

第四种:所有读端被关闭,写进程会收到信号退出。

2.5. 设置为非阻塞的方法
//获取原来的flags
int flags = fcntl(fds[0],F_GETFL);
//设置新的flags
flags |= O_NONBLOCK;//flags = flags | O_NONBLOCK;
fcntl(fds[0],F_SETFL,flags);

如果写端没有关闭,读端设置为非阻塞,如果读完了数据,直接返回-1。

2.6. 查看管道缓冲区大小

i)命令

ulimit -a,输出的内容中看pipe size

ii)函数

3. 有名管道(FIFO)

命名管道提供了一个路径与之关联,以FIFO的文件形式存在于文件系统中,只要可以访问该路径,就可以彼此通信。

3.1. FIFO和pipe的不同之处

FIFO和pipe的不同之处:

  1. FIFO是一个特殊的文件,但是内容存放在内存中
  2. 使用FIFO的进程退出后,FIFO文件将继续留存以便以后使用
  3. FIFO由名字,不相关的进程可用通过打开命名管道进行通信
3.2. 创建有名管道

i)命令

mkfifo [fifoname]

ii)函数

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>

//通过mkfifo函数创建一个管道文件
int main(){
    int ret = -1;
    ret = mkfifo("fifo",0644);
    if(ret = -1){
        perror("mkfifo");
        return 1;
    }
	printf("创建成功!\n");
    
    return 0;
}
3.3. 有名管道读写操作

常见的I/O函数都可用于fifo,如:close,open,read,write,unlink等,但是不支持lseek等定位操作,fifo是先进先出的

//进程1 写操作
int fd = open("my_fifo",O_WRONLY);
char send[100]="hello mike";
write(fd,send,strlen(send));

//进程2 读操作
int fd = open("my_fifo",O_RDONLY);
char recv[100]={0};
read(fd,recv,sizeof(recv));
printf("read from my_fifo buf=[%s]\n",recv);
3.4. 有名管道注意事项

3.5. 使用有名管道实现简单版本的聊天

思路:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>

#define SIZE 128

//talkA先读后写
int main(){
	int fdr = -1;
	int fdw = -1;
	int ret = -1;
	
	char buf[SIZE];

	fdr = open("fifo1",O_RDONLY);
	if(fdr == -1)
	{
		perror("open");
		return 1;
	}
	printf("只读方式打开管道1\n");

	fdw = open("fifo2",O_WRONLY);
	if(fdw == -1)
	{
		perror("open");
		return 1;
	}
	printf("只写方式打开管道2\n");

	//循环读写
	while(1)
	{	
		//读管道
		memset(buf,0,SIZE);
		ret=read(fdr,buf,SIZE);
		if(ret<=0)
		{
			perror("read");
			break;
		}
		printf("read:%s\n",buf);
		
		//写管道
		memset(buf,0,SIZE);
		fgets(buf,SIZE,stdin);
		if(buf[strlen(buf)-1] == '\n') buf[strlen(buf)-1] = '\0';
		ret = write(fdw,buf,strlen(buf));
		if(ret<=0)
		{
			perror("write");
			break;
		}
		printf("write ret:%d\n",ret);
	}

	close(fdw);
	close(fdr);
	return 0;
}
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>

#define SIZE 128

//talkB先写后读
int main(){
	int fdr = -1;
	int fdw = -1;
	int ret = -1;
	
	char buf[SIZE];

	fdw = open("fifo1",O_WRONLY);
	if(fdw == -1)
	{
		perror("open");
		return 1;
	}
	printf("只写方式打开管道1\n");

	fdr = open("fifo2",O_RDONLY);
	if(fdr == -1)
	{
		perror("open");
		return 1;
	}
	printf("只读方式打开管道2\n");

	//循环读写
	while(1)
	{	
		//写管道
		memset(buf,0,SIZE);
		fgets(buf,SIZE,stdin);
		if(buf[strlen(buf)-1] == '\n') buf[strlen(buf)-1] = '\0';
		ret = write(fdw,buf,strlen(buf));
		if(ret<=0)
		{
			perror("write");
			break;
		}
		printf("write ret:%d\n",ret);
		
		//读管道
		memset(buf,0,SIZE);
		ret=read(fdr,buf,SIZE);
		if(ret<=0)
		{
			perror("read");
			break;
		}
		printf("read:%s\n",buf);
	}

	close(fdw);
	close(fdr);
	return 0;
}
OBJS = talkA talkB

all:$(OBJS)

talkA:talkA.c
	gcc $< -o $@

talkB:talkB.c
	gcc $< -o $@

.PHONY:clean
clean:
	rm -rf talkA talkB
4. 共享存储映射
4.1. 概述

存储映射I/O(Memory-mapped I/O)使一个磁盘文件与存储空间中的一个缓冲区相映射。

共享内存可以说是最有用的进程间通信方式。当从缓冲区中取数据,就相当于读文件中的相应字节。这样,就可以在不适用read和write函数的情况下,使用地址(指针)完成I/O操作。进程不需要拷贝任何数据,而是直接读写内存。

4.2. 存储映射函数

i)mmap函数(创建映射)

关于mmap使用总结:

1)第一个参数写成NULL

2)第二个参数要映射文件的大小>0,length是以bit为单位

3)第三个参数:PROT_READ、PORT_WRITE

4)第四个参数:MAP_SHARED或者MAP_PRIVATE

5)第五个参数:打开的文件对应的文件描述符

6)第六个参数:4k的整数倍,通常填0

ii)munmap函数(断开映射)

addr是创建映射成功时的返回值

4.3. 创建文件映射示例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>

int main{
    int fd = -1;
    int ret = -1;
    void *addr = NULL;
	
    //1.打开文件(读写)
    fd = open("txt",O_RDWR);
    if(fd == -1){
        perror("open");
        return 1;
    }

    //2.将文件映射到内存
    addr = mmap(NULL,1024,PROT_READ | PROT_WRITE,MAP_SHARED,fd,0);
    if(addr == MAP_FAILED){
        perror("mmap");
        return 1;
    }
    printf("文件存储映射ok...\n");

    //3.关闭文件
    close(fd);

    //4.写文件
    memcpy(addr,"1234567890",10);

    //5.断开映射
    munmap(addr,1024);

    return 0;
}
4.4. 存储映射注意事项

1)创建映射时,隐含了对映射文件的读操作

2)当MAP_SHARED时,映射区的权限<=文件打开权限(保护映射区)。而当MAP_PRIVATE时,不需要考虑,因为是写时拷贝

3)映射成功,文件就可以关闭

4)创建映射区容易出错,注意检查返回值再进一步操作

4.5. 共享映射实现父子进程通信
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <wait.h>

int main{
    int fd = -1;
    void *addr = NULL;
	int pid = -1;
    
    //1.打开文件(读写)
    fd = open("txt",O_RDWR);
    if(fd == -1){
        perror("open");
        return 1;
    }

    //2.将文件映射到内存
    addr = mmap(NULL,1024,PROT_READ | PROT_WRITE,MAP_SHARED,fd,0);
    if(addr == MAP_FAILED){
        perror("mmap");
        return 1;
    }
    printf("文件存储映射ok...\n");

    //3.关闭文件
    close(fd);

    //4.创建一个子进程
    pid = fork();
    if(pid == -1){
        perror("fork");
        return 1;
    }
	//子进程
    if(pid == 0){
        //写文件
    	memcpy(addr,"1234567890",10);
    }
    else{
        //等待子进程结束
        wait();
        //父进程
        printf("addr:%s\n",(char *)addr);
    }

    //.断开映射
    munmap(addr,1024);

    return 0;
}

不同进程间通信的思路也类似

4.6. 匿名映射实现父子进程通信

Linux系统提供了创建匿名映射取得方法,每次创建时无需依赖一个现有的文件,但是需要借助标志位参数flags。

使用MAP_ANONYMOUS(或MAP_ANON)

int *p = mmap(NULL, 4, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
5. 信号的基本概念
5.1. 信号的概述

内核发送信号给进程,它是在软件层次上对中断机制的一种模拟,是一种异步通信方式。信号可以导致一个正在运行的进程被另一个正在运行的异步进程中断,转而处理某一个突发事件。

5.2. 信号的编号(使用kill -l查看)

POSIX.1对可靠信号例程进行了标准化。

使用kill -l,查看相应的编号

1~31为常规(普通、标准)信号,34~64为实时信号。

常见的信号:

信号

对应事件

默认动作

SIGINT

当用户按下<Ctrl+c>,用户终端向正在运行中的由该终端启动的程序发出此信号

终止进程

SIGQUIT

当用户按下<Ctrl+\>,用户终端向正在运行中的由该终端启动的程序发出信号

终止进程

SIGSEGV

指示进程进行了无效的内存访问(段错误)

终止进程并产生core文件

SIGPIPE

Broken pipe向一个没有读端的管道写数据

终止进程

SIGUSR1

用户定义的信号

终止进程

SIGCHILD

子进程结束,父进程会接收这个信号

忽略这个信号

5.3. 信号四要数(使用man signal 7查看)

编号、名称、事件、默认处理动作

通过man signal 7查看帮助文档获取

5.4. 信号的状态和信号集

i)信号的状态

1)产生

2)未决状态:没有被处理

3)递达状态:信号被处理了

ii)阻塞信号集和未决信号集

PCB中包含的信号相关信息,主要指阻塞信号集和未决信号集。

6. 信号产生函数
6.1. kill函数(向指定进程发送信号)

第二个参数建议使用宏名,以避免部分操作系统信号编号不同的问题。

root用户可以给任何用户发送信号,普通用户是不能向系统用户发送信号的。

普通用户只能给自己创建的进程发送信号。

6.2. rasie函数(向自己发送信号)

6.3. abort函数(向自己发送异常终止信号)

6.4. alarm函数(内核定时器,是自然定时的,超时默认终止进程)

6.5. setitimer函数(定时器)

示例:

7. 信号捕捉
7.1. signal函数(不建议使用)

i)信号处理方式

捕捉信号后可以进行一些自定义操作,比如忽略、默认处理、自定义回调函数。

需要注意的是SIGKILL和SIGSTOP不能被更改处理方式。

ii)先来看一个typedef

图中用typedef给一个返回值为空的函数指针取了一个别名——sighandler_t,最下方的函数可以还原为void (*signal (int sigum, void (*handler) (int))) (int);

iii)signal函数

不太建议使用,因为历史原因,不同版本可能有不同动作。取而代之的是使用sigaction函数。

异步:突发事件。

7.2. sigaction函数(建议使用)

  • sa_handler、sa_sigaction:信号处理函数指针,和signal()里函数指针的用法一样,应根据情况给二者之一赋值如下:

a)SIG_IGN:忽略该信号

b)SIG_DFL:执行系统默认动作

c)回调函数名:执行自定义回调函数

  1. sa_mask:信号阻塞集,在信号处理函数执行过程中临时屏蔽的信号。
  2. sa_flags:指定信号处理的行为,通常为0,表示使用默认属性。

示例:

  • 旧的信号处理函数

  • 新的信号处理函数

8. 可重入、不可重入函数的概念

可重入函数是指,可以被多次调用而不会导致不确定的结果或错误。这是因为可重入函数在执行过程中不使用全局变量或静态变量,而是使用局部变量和参数来保存临时数据。可重入函数是线程安全的,每个线程在调用该函数时,都会创建自己的局部变量和参数,不会相互干扰。

不可重入函数是指在多线程环境下,不能被多个线程同时调用,否则可能导致不确定的结果或错误。这是因为不可重入函数在执行过程中使用了全局变量或静态变量来保存临时数据,多个线程同时调用时会相互竞争修改这些共享数据,导致结果不可预测。

在编写多线程程序时,需要特别关注函数是否是可重入的。如果函数不是可重入的,需要采取适当的同步机制(如互斥锁)来保证在多线程环境下的正确执行。

需要注意的是,可重入函数并不一定是线程安全的。虽然可重入函数在多线程环境下可以被多个线程同时调用,但如果在函数内部访问了共享的非线程安全资源,仍然可能导致问题。因此,在编写多线程程序时,需要综合考虑函数的可重入性和线程安全性。

9. SIGCHLD信号(避免僵尸进程)
9.1. SIGCHLD信号产生的条件
  1. 子进程终止时
  2. 子进程接收到SIGSTOP信号停止时
  3. 子进程处在停止态,接受到SIGCONT后唤醒时
9.2. 如何避免僵尸进程
  1. 父进程通过wait()和waitpid()函数来等待子进程结束,但是这会让父进程挂起
  2. 通过sigaction()函数人为处理信号SIGCHLD,写一个回调函数,在回调函数中使用wait()或waitpid()
  3. 父进程不关心子进程什么时候结束,使用sigaction(SIGCHLD, struct &act, NULL)通知内核父进程会忽略这个信号,那么子进程结束之后,内核就会回收,并且不给父进程发信号。(act.sa_handler = SIG_IGN; act.sa_flags = 0;)
10. 信号集
10.1. 信号集概述

阻塞信号集类似于黑名单,而未决信号集类似于未接来电。

用户可以设置阻塞信号集(可读写),而未决信号集是由内核设置(只读)。

要操作信号集,需要用到信号集函数,而且需要自定义另外一个集合

10.2. 自定义信号集函数

使用前自定义一个信号集

#include <signal.h>

int sigemptyset(sigset_t *set);			//将信号集置空
int sigfillset(sigset_t *set);			//将所有信号加入到set集合
int sigaddset(sigset_t *set, int signo);//将指定信号添加进set
int sigdelset(sigset_t *set, int signo);//移除指定信号
int sigismember(const sigset_t *set, int signo);//判断信号是否存在
10.3. sigprocmask函数(改变信号阻塞情况)

信号阻塞集也叫信号屏蔽集、信号掩码。每个进程都有一个阻塞集,用来描述哪些信号需要被阻塞(暂缓传送信号,屏蔽

10.4. sigpending(获取未决信号集)

第八章 进程组和守护进程

1. 终端的概念(了解)

系统与用户交流的界面称为终端。

在UNIX系统中,用户通过终端登录系统后获得一个Shell进程,这个终端成为Shell进程的控制终端,控制终端的信息保存在PCB,而fork会复制PCB中的信息,因此Shell启动的其他进程的控制终端也是这个终端。

tty查看当前终端名字(Linux命令)

i)相关函数

2. 进程组的概念

进程组,也称为作业,代表一个或多个进程的集合。

当父进程创建子进程时,默认子进程和父进程属于同一进程组。进程组ID为第一个进程ID(组长进程)。

i)相关函数

3. 会话及其API

会话是一个或多个进程组的集合(一个前台进程组,多个后台进程组);

建立与控制终端练习的会话称为控制进程;

如果终端接口检测到断开连接,则将挂断信号发送给控制进程(会话首进程)。

i)创建会话的注意事项

ii)相关API介绍

  • getsid函数:

  • setsid函数:

4. 守护进程(重点)
4.1. 守护进程介绍

守护进程(Daemon Process),也叫精灵进程,是Linux中后台服务进程。它的生存周期较长,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件,名字一般以d结尾。其执行过程中的信息不会在任何终端显示。

Linux的大多数服务器就是守护进程实现的。比如,Internet服务器inetd,Web服务器httpd等。

4.2. 守护进程模型

1)创建子进程,父进程退出(必须)

在子进程中的所有工作,形式上脱离了控制终端。

2)在子进程中创建新的会话(必须)

setsid()函数;

使子进程完全独立出来,脱离控制。

3)改变当前目录为根目录(非必须)

chdir()函数;

防止占用可卸载的文件系统;

也可换成其他路径。

4)重设文件权限掩码(非必须)

umask()函数,临时设置掩码(文件最大权限是0666,一个0664文件用umask查看会返回0002);

防止继承的文件创建屏蔽字拒绝某些权限;

增加守护进程灵活性。

5)关闭文件描述符(非必须)

继承的打开文件不会用到,浪费系统资源,无法卸载。

6)开始执行守护进程核心工作(必须)

守护进程退出处理程序模型。

4.3. 守护进程参考代码

4.4. 获取系统当前时间(结合守护进程用于创建日志文件)
#include <time.h>
time_t t = -1;
//获取时间 秒为单位 从1970年1月1日00:00:00开始
t = time(NULL);
printf("t: %ld\n",t);

//获取时间 输出字符串 Sun Dec 18:09:12 2023
printf("ctime: %s\n",ctime(&t));

//获取时间
#define SIZE 64
char file_name[SIZE];
struct tm *pT = NULL;
pT = localtime(&t);
// printf("year: %d\n",pT->tm_year + 1900);
// printf("month: %d\n",pT->tm_mon + 1);
// printf("day: %d\n",pT->tm_mday);
// printf("hour: %d\n",pT->tm_hour);
// printf("min: %d\n",pT->tm_min);
// printf("sec: %d\n",pT->tm_sec);
//转化为文件名
memset(file_name, 0, SIZE);
sprintf(file_name, "%s%d%d%d%d%d%d.log",
    "touch /home/wishmeluck/", pT->tm_year + 1900, pT->tm_mon + 1, pT->tm_mday, pT->tm_hour, pT->tm_min, pT->tm_sec);
// printf("file_name:%s\n",file_name);
//创建文件
system(file_name);

第九章 线程

1. 线程的基本概念

线程是轻量级的进程(LWP:light weight process),在Linux环境下线程的本质仍是进程。为了让进程完成一定的工作,进程至少包含一个线程。

(BSS区存放未初始化的数据段,数据段则是存放全局变量、静态局部变量)

进程是CPU分配资源的最小单位,线程是操作系统调度执行的最小单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同一个进程的其他线程共享进程所拥有的全部资源。

i)线程函数列表的安装

sudo apt install manpages-posix-dev

其中包含POSIX的header files和library calls的用法,用man -k pthread查看

ii)NPTL

Native POSIX Thread Library,是Linux线程的一个实现,使用getconf GNU_LIBPTHREAD_VERSION查看当前pthread版本

2. 线程的特点和优缺点

实际上,无论是创建进程的fork,还是创建线程的pthread_create,底层实现都是调用同一个内核函数clone:

  1. 如果复制对方的地址空间,就产生一个“进程”
  2. 如果共享对方的地址空间,就产生一个“线程”

线程所有的操作函数pthread_*是库函数,而非系统调用。

i)线程共享资源

  1. 文件描述符
  2. 每种信号的处理方式
  3. 当前工作目录
  4. 用户ID和组ID
  5. 内存地址空间(.text/.data/.bss/heap/共享库)

ii)线程非共享资源

  1. 线程ID
  2. 处理器现场和栈指针(内核栈)
  3. 独立的栈空间(用户空间栈)
  4. errono变量
  5. 信号屏蔽字
  6. 调度优先级

iii)优点

  1. 提高程序并发性
  2. 开销小
  3. 数据通信、共享数据方便

iiii)缺点

  1. 都是库函数,不稳定
  2. 调度、编写困难、gdb不支持
  3. 对信号支持不好

优点相对突出,缺点均不是硬伤。

3. 线程常用操作
3.1. 线程号

线程号只在它所属的进程环境中有效。

进程号是pid_t,是一个非负整数。线程号是pthread_t,Linux中是无符号长整型。

有的系统在实现pthread_t的时候,用一个结构体表示,所以在可移植的操作系统是实现不能把它作为整数处理。

3.2. pthread_self函数(获取线程号)

使用pthread_*的库函数,在编译的时候需要链接到-pthread

3.3. pthread_equal函数(对比两个线程是否相同)

因为有些系统使用结构体实现pthread_t,所以最好使用equal来比较,不要使用==

3.4. pthread_create函数(创建线程)

打印错误信息时先用strerror把错误码转换为错误信息。

3.5. pthread_join函数(资源回收)

第二个参数传入一级指针

3.6. 线程分离pthread_detach函数

一般情况下,线程终止后,其终止状态一直保留到其它线程调用pthread_join获取它的状态为止。但是线程也可以设置为detach状态,这样的线程一旦终止就立刻回收它所占用的所有资源,而不保留终止状态。

不能对一个detach状态额的线程调用pthread_join。

3.7. 线程退出pthread_exit函数

在一个线程中,我们可以通过以下三种在不终止整个进程的情况下停止它的控制流:

  1. 线程从执行函数中返回
  2. 线程调用pthread_exit退出
  3. 线程可以被同一进程中的其它线程取消

3.8. 线程取消pthread_cancel函数

线程的取消有一定延时,需要等待到达某个取消点。

取消点:是线程检查是否被取消,并按请求进行动作的一个位置,通常是一些系统调用,如create、open、pause、close、read、write...执行man pthreads 7可以查看这些取消点的系统调用列表。

可以粗略认为一个系统调用即为一个取消点。

4. 线程属性(了解)
4.1. 概述

常用情况下,使用的都是线程的默认属性。也可以自定义。

主要结构体成员:

  1. 线程分离状态
  2. 线程栈大小(默认平均分配)
  3. 线程栈警戒缓冲区大小(位于栈末尾)
  4. 线程栈最低地址
4.2. 线程属性初始化和销毁

4.3. 线程分离状态

非分离状态:默认,原有线程等待创建的线程结束,只有当pthread_join()函数返回时,创建的线程才终止,释放占用的系统资源。

分离状态:自己运行结束了,线程就终止,释放资源。

5. 线程使用的注意事项
  1. 避免在多线程模型中调用fork(子进程中只有调用fork的线程存在,其他的均退出);避免引入信号机制(语义复杂,多线程共存问题)。
  2. malloc和mmap申请的内存可以被其他的线程释放。

第十章 线程同步

1. 互斥锁(Mutex)
1.1. 同步、互斥

互斥:当某个任务运行一个程序片段时,其他任务就无法运行他们之中的任意程序片段(一个公共资源同一时刻只能被一个进程或线程使用)。

同步:(不能同时执行,按预定的先后次序运行)。

1.2. 互斥锁Mutex

原子操作:不可再分割的操作

互斥锁的数据类型:pthread_mutex_t

安装指令:sudo apt install manpages-posix-dev

1.3. 初始化、销毁、加锁、解锁(四个函数)
  • pthread_mutex_init函数

restrict,C语言中的一种类型限定符,用于通知编译器,对象已经被指针所引用,不能通过除该指针外其他的方式修改该对象的内容。

  • pthread_mutex_destory函数

  • pthread_mutex_lock函数

  • pthread_mutex_unlock函数

  • 示例

2. 死锁(Deadlock)
2.1. 概述

因为资源竞争或彼此通信二日造成的阻塞的现象,永远相互等待。

2.2. 死锁的原因
  • 竞争不可抢占资源

如上。

  • 竞争可消耗资源

有p1、p2、p3三个进程,p1向p2发送并接受p3的消息,p2向p3发送并接受p1的消息,p3向p1发送并接受p2的消息,如果设置先收再发,则所有的消息都不能发送,环形死锁。

2.3. 死锁的必要条件
  • 互斥条件

某资源只能被一个进程使用,其他进程请求该资源时,只能等待,直到资源释放。

  • 请求和保持条件

程序已经保持了至少一个资源,但是又提出了新要求,而这个资源被其他进程占用,自己占用资源却保持不放。

  • 不可抢占条件

进程的资源没有被使用完,不能被抢占。

  • 循环等待条件

存在一个循环链。

2.4. 处理死锁的思路
  • 预防

破坏死锁的四个必要条件中的一个或多个。

  • 避免

在分配资源的过程中,用某种方式防止系统进入不安全状态。

  • 检测

运行时出现死锁,及时发现,并解除死锁

  • 解除死锁

发生死锁后,通常撤销进程,回收资源,再分配给正处于阻塞状态的进程。

2.5. 预防死锁的方法

破坏请求和保持条件

  1. 协议1:所有进程开始前,必须一次性地申请所需的所有资源
  2. 协议2:允许一个进程只获得初期的资源就开始运行,但是运行完的资源需要释放出来,才能请求新的资源。

破坏不可抢占条件

当一个保持了某种不可抢占资源的进程,提出新资源请求不能被满足时,它必须释放已经保持的所有资源,以后有需要再重新申请。

破坏循环等待条件

对系统中的所有资源类型进行线性排序,然后规定每个进程必须按序列号递增的顺序请求资源。

3. 读写锁
3.1. 概述

场景:读多写少

特点:

  1. 如果有线程读,则允许其他线程读,但不允许写。
  2. 如果有线程写,则其他线程都不允许读、写。

读锁、写锁:

  1. 有线程申请了读锁,其他线程可再申请读锁。
  2. 有线程申请了写锁,其他线程不能申请读锁,也不能申请写锁。

POSIX定义的读写锁数据类型:pthread_rwlock_t

3.2. 初始化、销毁、读锁、写锁、解锁(五个函数)
  • pthread_rwlock_init函数

  • pthread_rwlock_destory函数

  • pthread_rwlock_rdlock函数

  • pthread_rwlock_wrlock函数

  • pthread_rwlock_unlock函数

4. 条件变量
4.1. 概述

与互斥锁不同,条件变量是用来等待而不是用来上锁的。

条件变量用来自动阻塞一个线程,直到某特殊情况发生,通常和互斥锁同时使用。

条件变量的两个动作:

  1. 条件不满,阻塞线程
  2. 条件满足,通知被阻塞线程开始工作

条件变量的数据类型:pthread_cond_t

4.2. 初始化、销毁、等待、发讯(四个函数)
  • pthread_cond_init函数

  • pthread_cond_destory函数

  • pthread_cond_wait函数

限时等待:

  • pthread_cond_signal函数

4.3. 示例
#include<stdio.h>
#include<pthread.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>

pthread_mutex_t mutex;
pthread_cond_t cond;
int flag = 0;

void *func1(void *arg){
	while(1){
        //加锁 改状态
		pthread_mutex_lock(&mutex);
		flag = 1;
		pthread_mutex_unlock(&mutex);
    	//唤醒条件
		pthread_cond_signal(&cond);
		printf("condition val...\n");
		sleep(2);
	}
	return NULL;
}

void *func2(void *arg){
	while(1){
		pthread_mutex_lock(&mutex);
		if(flag == 0){
			pthread_cond_wait(&cond,&mutex);
		}
		printf("flag = %d\n",flag);
		printf("working...\n");
        //工作完 状态改回0
		flag = 0;
		pthread_mutex_unlock(&mutex);
	}
	return NULL;
}

int main(){
	int ret = -1;
	pthread_t tid1, tid2;
	
    //初始化
	ret = pthread_cond_init(&cond, NULL);
	if(ret != 0){
		printf("pthread_cond_init failed...\n");
		return 1;
	}
	ret = pthread_mutex_init(&mutex, NULL);
	if(ret != 0){
		printf("pthread_mutex_init failed...\n");
		return 1;
	}
	
	//创建两个线程
	pthread_create(&tid1, NULL, func1, NULL);
	pthread_create(&tid2, NULL, func2, NULL);

	//回收线程资源
	ret = pthread_join(tid1, NULL);
	if(ret != 0){
		printf("pthread_join failed...\n");
		return 1;
	}
	ret = pthread_join(tid2, NULL);
	if(ret != 0){
		printf("pthread_join failed...\n");
		return 1;
	}

    //销毁锁和条件变量
	pthread_mutex_destroy(&mutex);
	pthread_cond_destroy(&cond);

	return 0;
}
4.4. 生产者和消费者条件变量模型

借助条件变量来实现线程同步,两个线程(生产者、消费者),一个向汇聚中添加物品,一个消费掉物品。

5. 信号量
5.1. 概述

信号量广泛用于进程或线程间的同步和互斥,本质上是一个非负整数的计数器,用来控制对公共资源的访问。(可根据信号量值的结果判定是否对公共资源具有访问权限,信号量大于0,则可以访问,否则阻塞)

信号量数据类型为:sem_t

PV原语是对信号量的操作,一次P(占用资源)操作使信号量减1,一次V(释放资源)操作使信号量加1。

信号量用于互斥:

用于同步:

5.2. 初始化、销毁、占用资源、释放资源(四个函数)
  • sem_init函数

  • sem_destroy函数

  • 信号量P操作(减1)

可以理解为通过

  • 信号量V操作(加1)

可以理解为释放

  • 获取信号量的值

5.3. 生产者和消费者信号量模型
#include<stdio.h>
#include<semaphore.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<pthread.h>

//声明两个信号量
sem_t sem_customer;
sem_t sem_producer;

//单链表
typedef struct _node_t{
	int data;
	struct _node_t *next;
}node_t;
node_t *head = NULL;

//生产者
void *producer(void *args){
	node_t *inventory = NULL;
	while(1){
		//申请一个资源
        sem_wait(&sem_producer);
		
		inventory = malloc(sizeof(node_t));
		if(inventory == NULL){
			printf("malloc failed...\n");
			break;
		}
		memset(inventory, 0 ,sizeof(node_t));

		inventory->data = random() % 100 + 1;
		inventory->next = NULL;
		printf("生产者生产%d\n", inventory->data);

		inventory->next = head;
		head = inventory;

        //可消费物品加一
		sem_post(&sem_customer);	
		sleep(random() % 3 + 1);
	}
	return NULL;
}

void *customer(void *args){
	node_t *tmp = NULL;
	
	while(1){
        //等待有可消费物品出现
		sem_wait(&sem_customer);
		
		if(head == NULL){
			printf("产品列表为空...\n");
		}
		tmp = head;
		head = head->next;
		
		printf("消耗产品%d...\n",tmp->data);
		free(tmp);

        //可生产+1
		sem_post(&sem_producer);
		sleep(random() % 3 + 1);	
	}
	return NULL;
}


int main(){
	int	ret = -1;
	pthread_t tid1, tid2;

	srandom(getpid());

	ret = sem_init(&sem_producer, 0, 4);
	if(ret != 0){
		printf("sem_pro_init failed...\n");
		return 1;
	}
	ret = sem_init(&sem_customer, 0, 0);
	if(ret != 0){
		printf("sem_cus_init failed...\n");
		return 1;
	}

	pthread_create(&tid1, NULL, producer, NULL);
	pthread_create(&tid2, NULL, customer, NULL);

	pthread_join(tid1, NULL);
	pthread_join(tid2, NULL);
	
	sem_destroy(&sem_producer);
	sem_destroy(&sem_customer);
	return 0;
}
5.4. 哲学家就餐问题

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

WISHMELUCK1'

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值