Shell

终端:一些列输入输出设备的总称

Shell家族

Shell:命令解释器,根据输入的命令执行相应命令

查看系统有哪些shell:

cat /etc/shells

查看当前系统正在使用的shell:

echo $SHELL

常见shell:

/bin/sh(已被/bin/bash取代)
/bin/bash(就是Linux默认的Shell)
/bin/ksh(Kornshell有AT&T Bell lab发展出来, 兼容bash)
/bin/tcsh(整合C Shell, 提供更多的功能)
/bin/csh(已被/bin/tcsh取代)
/bin/zsh(基于ksh发展出来, 功能更强大的shell)

bash

bash是一个为GNU计划便携的Unix shell。它的名字是一系列缩写:Bourne-Again Shell

bash是许多Linux平台的内定Shell,事实上还有许多传统UNIX上用的Shell,像tcsh、csh、ash、bsh、ksh等,Shell Script大致相同

命令和路径补齐

在bash下敲命令时,Tab键可以补全已经敲了一部分的文件名和目录名。如果时Ubuntu系统,系统默认启用了bash completion,还可以补全命令的某些参数、Makefile目录等。如果是Debian系统,可以用一下命令启用bash completion:

source /etc/bash_completion

建议将这一行加入~/.bashrc启动脚本中。比如使用sudo后面接命令,如果没有bash completion则只有sudo命令可以补全,后面的命令不能补全。如果启用了bash completion,则后面的命令,包括命令的参数(比如aptitude命令的install)都可以补全

主键盘快捷键

功能 快捷键 助记
Ctrl-p previous
Ctrl-n next
Ctrl-b backword
Ctrl-f forward
Del Ctrl-d delete光标后面的
Home Ctrl-a the first letter
End Ctrl-e end
Backspace Backspace delete光标前面的

Linux操作系统

参考Linux操作系统blog

文件和目录

文件IO

什么是系统调用:由操作系统实现提供给外部应用程序的接口(Application Programming Interface,API),是应用程序同系统之间数据交互的桥梁

在学习Linux系统编程时,注意区分系统调用和库函数

open函数和close函数

函数原型:

  • int open(const char *pathname, int flags);
  • int open(const char *pathname, int flags, mode_t mode);
  • int close(int fd);

函数参数:

  • C文件打开模式标记:O_RDONLY、O_WRONLY、O_RDWR、O_APPEND、O_CREAT、O_EXCL、O_TRUNC、O_NONBLOCK
  • 新创建文件时:mode_t为8进制的umask
模式标记 适用对象 作用
ios::in ifstream、fstream 打开文件用于读取数据。如果文件不存在,则打开出错
ios::out ofstream、fstream 打开文件用于写入数据。如果文件不存在,则新建该文件;如果文件原来就存在,则打开时清除原来内容
ios::app ofstream、fstream 打开文件,用于在其尾部添加数据。如果文件不存在,则新建该文件
ios::ate ifstream 打开一个已有的文件,并将文件读指针指向文件末尾。如果文件不存在,则打开出错
ios::trunc ofstream 打开文件时会清空内部存储的所有数据,单独使用时与ios::out相同
ios::binary ifstream、ofstream、fstream 以二进制方式打开文件。若不指定此模式,则以文本模式打开
ios:: | ios::out fstream 打开已存在文件,既可读取其内容,也可向其写入数据。文件刚打开时,原有内容保持不变。如果文件不存在,则打开出错
ios::in | ios:: out ofstream 打开已存在的文件,可以向其写入数据。文件刚打开时,原有内容保持不变。如果文件不存在,则打开出错
ios::in | ios::out | ios::trunc fstream 打开文件,既可读取其内容,也可向其写入数据。如果文件本来存在,则打开文件时清除原来的内容;如果文件不存在,则新建该文件

创建文件时,指定文件访问权限。权限同时受umask影响,文件权限=mode & ~umask

使用头文件:<unistd.h>、<fcntl.h>

#include <stdio.h>
#include <unistd.h> // 系统调用
#include <fcntl.h>
#include <errno.h>
#include <string.h>

int main() {
// 返回文件描述符指针地址: 标准输入为0、标准输出为1、标准出错为2、用户描述符从3开始
int fd = open("../test.txt", O_RDONLY | O_CREAT, 0644); // 文件存在时, 打开文件. 文件不存在时, 创建文件并指定umask为644(rw-r--r--)

printf("fd = %d, errno = %d:%s\n", fd, erron, strerror(erron));

close(fd);
return 0;
}

write函数和read函数

read函数原型:

  • ssize_t read(int fd, void *buf, size_t count);

函数参数:

  • fd(文件描述符)、buf(存数据的缓冲区)、count(缓冲区大小)

返回值:

  • 成功(读到的字节数)、0为文件结尾、失败(-1,设置errno)(-1并且errno=EAGIN或EWOULDBLOCK,说明不是read失败,而是read在以非阻塞方式读一个文件设备或网络设备,并且文件无数据)

write函数原型:

  • ssize_t write(int fd, const void *buf, size_t count);

函数参数:

  • fd(文件描述符)、buf(待写出数据的缓冲区)、count(数据大小)

返回值:

  • 成功(写入的字节数)、失败(-1,设置errno)
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>

int main() {
char buf[1024];

int n = 0;
int fd1 = open("../test1.txt", O_RDONLY);
int fd2 = open("../test2.txt", O_RDWR | O_CREAT | O_TRUNC, 0664);

while((n = read(fd1, buf, 1024)) != 0) {
write(fd2, buf, n); // 实现拷贝功能
}

close(fd1);
close(fd2);

return 0;
}
进程地址空间

阻塞、非阻塞

读常规文件是不会阻塞的,不管读多少字节,read一定会在有限的时间内返回。从终端设备或网络读则不一定,如果从终端输入的数据没有换行符,调用read读终端设备就会阻塞;如果网络上没有接收到数据包,调用read从网络读就会阻塞,至于会阻塞多长时间也不是确定的,如果一直没有数据达到就一直阻塞在那里。同样,写常规文件是不会阻塞的,面向终端设备或网络写则不一定

现在明确一下阻塞(Block)这个概念。当进程调用一个阻塞的系统函数时,该进程被置于睡眠(Sleep)状态,这时内核调度其他进程运行,直到该进程等待的事件发生了(比如网络上接收到数据包,或者调用sleep指定的睡眠时间到了)它才有可能继续运行。与睡眠状态相对的是运行(Running)状态,在Linux内核中,处于运行状态的进程分为两种情况:

  • 正在被调度执行。CPU处于该进程的上下文环境中,程序计数器(eip)里保存着该进程的指令地址,通用寄存器里保存着该进程运算过程的中间结果,正在执行该进程的指令,正在读写该进程的地址空间
  • 就绪状态。该进程不需要等待什么事情发生,随时都可以执行,但CPU暂时还在执行另一个进程,所以该进程在一个就绪队列中等待被内核调度。系统中可能同时有多个就绪的进程,那么该调度谁执行呢?内核的调度算法是基于优先级和时间片的,而且会根据每个进程的运行情况动态调整它的优先级和时间片,让每个进程都能比较公平地得到机会执行,同时要兼顾用户体验,不能让和用户交互的进程相应太慢

阻塞是设备文件或网络文件的属性

fcntl函数

函数原型:

  • int fcntl(int fd, int cmd, ... /* arg */);

获取文件状态:F_GETFL

设置文件状态:F_SETFL

#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <errno.h>

#define MSG_TRY "try again\n"

int main(void) {
char buf[10];
int flags, n;

flags = fcntl(STDIN_FILENO, F_GETFL); // 获取stdin属性
if(flags == -1) {
perror("fcntl error");
exit(1);
}

flags |= O_NONBLOCK;
int ret = fcntl(STDIN_FILENO, F_SETFL, flags); // 给标准输入stdin设置非阻塞状态
if(ret == -1) {
perror("fcntl error");
exit(1);
}

tryagain:
n = read(STDIN_FILENO, buf, 10);
if(n < 0) {
if(erron != EAGAIN) {
perror("read /dev/tty");
exit(1);
}
sleep(3);
write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
goto tryagain;
}
write(STDOUT_FILENO, buf, n);

return 0;

}

lseek函数

Linux中可使用系统函数lseek来修改文件偏移量(读写位置)

每个打开的文件都记录着当前读写位置,打开文件时读写位置是0,表示文件开头,通常读写多少字节就会将读写位置往后移多少个字节。但是有一个例外,如果以O_APPEND方式打开,每次写操作都会在文件末尾追加数据,然后将读写位置移到新的文件末尾。lseek和seekg(输入文件流)、seekp(输出文件流)函数类似,可以移动当前读写位置(或叫偏移量)

函数原型:

  • off_t lseek(int fd, off_t offset, int whence);

函数参数:

  • fd(文件描述符)、offset(偏移量)、whence(偏移的起始位置,可以是开头或当前位置)

返回值:

  • 成功(较起始位置的偏移量)、失败(-1 errno)

应用场景:

  • 文件的读、写使用同一偏移位置
  • 使用lseek获取扩展文件大小
  • 使用lseek拓展文件大小

在C语言中文件读和写使用的是同一个偏移

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

int main() {
int fd, n;
char msg[] = "It's a test for lseek\n";
char ch;

fd = open("../lseek.txt", O_RDWR | O_CREAT, 0644);
if(fd < 0) {
perror("open lseek.txt error");
exit(1);
}

write(fd, msg, strlen(msg));

lseek(fd, 0, SEEK_SET);

while(n = read(fd, &ch, 1)) {
if(n < 0) {
perror("read error");
exit(1);
}
}

close(fd);
return 0;
}

传入参数和传出参数

传入参数:例如char *strcpy(char *dest, const char *src);函数中src参数

  • 指针作为函数参数
  • 通常由const关键字修饰
  • 指针指向有效区域,在函数内部做读操作

传出参数:例如char *strcpy(char *dest, const char *src);函数中dest参数

  • 指针作为函数参数
  • 在函数调用之前,指针指向的空间可以无意义,但必须有效
  • 在函数内部做写操作
  • 函数调用结束后,充当函数返回值

传入传出参数:

  • 指针作为函数参数
  • 在函数调用之前,指针指向的空间有实际意义
  • 在函数内部,先做读操作,后做写操作
  • 函数调用结束后,充当函数返回值

文件系统

文件存储:了解inode、dentry、数据存储、文件系统

inode

其本质为结构体,存储文件的属性信息。如:权限、大小、时间、用户、盘块位置…也叫作文件属性管理结构,大多数的inode都存储在磁盘上,少量常用的inode会被缓存到内存中

dentry

目录项,其本质依然是结构体,重要成员变量有两个{文件名, inode, …},而文件内容(data)保存在磁盘盘块中

文件系统

文件系统是一组规则,规定对文件的存储及读取的一般方法。文件系统在磁盘格式化过程中指定。常见的文件系统有:fat32、ntfs、exfat、ext2、ext3、ext4

文件操作

stat函数和lsata函数

获取文件属性(从inode结构体中获取)

函数原型:

  • int stat(const char *path, struct stat *buf);
  • int lstat(const char *path, struct stat *buf);不会穿透符号链接

函数返回值:

  • 成功(0)、失败(-1 errno)

函数参数:

  • path(文件名)、buf(inode结构体指针)

获取文件大小:buf.st_size

获取文件类型:buf.st_mode

获取文件权限:buf.st_mode

#include <iostream>
#include <sys/stat.h>

using namespace std;

int main() {
const char* filename = "example.txt"; // 要查询的文件名
struct stat filestat; // 用于存储文件属性信息的结构体

// 调用stat()函数获取文件属性信息,如果返回值为-1则说明操作失败
if (stat(filename, &filestat) == -1) {
cerr << "Failed to get file status for " << filename << endl;
return 1;
}

// 输出文件属性信息
cout << "File size: " << filestat.st_size << " bytes" << endl;
cout << "Last modified: " << ctime(&filestat.st_mtime); // 将时间戳转换为可读格式输出
cout << "Permissions: " << oct << filestat.st_mode << endl; // 以八进制形式输出文件权限

// 判断文件是否为普通文件, 使用宏函数
if(S_ISREG(filestat.st_mode)) {
cout << "File is Regular" << endl; // 普通文件
} else if(S_ISDIR(filestat.st_mode)) {
cout << "File is Director" << endl; // 文件夹
} else if(S_ISFIFO(filestat.st_mode)) {
cout << "File is Pipe" << endl; // 管道
} else if(S_ISLNK(filestat.st_mode)) {
// 默认stat函数是会穿透符号链接的, 也就是说会找到软链接的原始文件, 如果我们不想让stat穿透则使用lstat函数
cout << "File is SYM Link" << endl; // 软链接
}

return 0;
}

access函数

测试指定文件是否存在/拥有某种权限

函数原型:

  • int access(const char *pathname, int mode);

返回值:

  • 成功(0)、失败(-1 errno)

chmod函数

修改文件的访问权限

函数原型:

  • int chmod(const char *path, mode_t mode);
  • int fchmod(int fd, mode_t mode);

返回值:

  • 成功(0)、失败(-1 errno)

truncate函数

截断文件长度成指定长度。常用来拓展文件大小,代替lseek

函数原型:

  • int truncate(const char *path, off_t length);
  • ftruncate(int fd, off_t length);

返回值:

  • 成功(0)、失败(-1 errno)

link函数

为什么目录项要游离于inode之外?

其目的是为了实现文件共享。Linux允许多个目录项共享一个inode,即共享盘块(data)。不同文件名在人类眼中将它理解成两个文件,但是在内核眼中是同一个文件

link函数可以为已经存在的文件创建目录项(硬链接)或移动文件

函数原型:

  • int link(const char *oldpath, const char *newpath);

返回值:

  • 成功(0)、失败(-1 errno)

注意:由于两个参数可以使用”相对/绝对路径+文件名”的方式来制定,所以容易出错

unlink函数

删除一个文件的目录项

函数原型:

  • int unlink(const char *pathname);

返回值:

  • 成功(0)、失败(-1 errno)

注意Linux下删除文件的机制:不断将st nlink -1,知道减到0为止。无目录项对应的文件将会被操作系统择机释放。因此,我们删除文件从某种意义上来说,只是让文件具备了释放的条件

unlink的特征:清楚文件时,如果文件的硬链接数到0了,没有的dentry对应文件也不会马上被释放,要等到所有代开该文件的进程关闭该文件,系统才会挑时间将该文件释放掉

// 实现文件改名
#include <stdio.h>
#include <unistd.h>

int main() {
char oldpath[] = "../test.txt";
char newpath[] = "../t.txt";

link(oldpath, newpath);
unlink(oldpath);

return 0;
}

隐式回收

当进程运行结束时,所有该进程打开的文件会被关闭,申请的内存空间会被释放。系统的这一特征称之为隐式回收系统资源

symlink函数

创建符号连接

函数原型:

  • int symlink(const char *oldpath, const char *newpath);

返回值:

  • 成功(0)、失败(-1 errno)

readlink函数

读取符号连接文件本身内容,得到链接所指向的文件名

函数原型:

  • ssize_t readlink(const char *path, char *buf, size_t bufsize);

返回值:

  • 成功(返回实际读到的字节数)、失败(-1 errno)

rename函数

重命名一个文件

函数原型:

  • int symlink(const char *oldpath, const char *newpath);

返回值:

  • 成功(0)、失败(-1 errno)

目录操作

工作目录:”./“代表当前目录,指的是进程当前的工作目录,默认是进程所执行的程序所在的目录位置

头文件:<dirent.h>

getcwd函数

获取去进程当前工作目录

函数原型:

  • char *getcwd(char *buf, size_t size);

返回值:

  • 成功(buf中保存当前进程工作目录位置)、失败(NULL)

chdir函数

改变当前进程的工作目录

函数原型:

  • int chdir(const char *path);

返回值:

  • 成功(0)、失败(-1 errno)

文件、目录权限

注意:Linux皆文件,目录也是文件。其文件内容是改目录下所有子文件的目录项dentry

r w x
文件 文件的内容可以被查看cat、more、less… 内容可以被修改vi… 可以运行产生进程./文件名
目录 目录可以被浏览ls、tree… 创建删除修改mv、touch、mkdir… 可以被打开、进入cd

目录设置黏住位:若有w权限,创建不变,删除、修改只能由root、目录所有者、文件所有者操作

opendir函数

根据传入的目录打开一个目录(库函数)

函数原型:

  • DIR *opendir(const char *name);

返回值:

  • 成功(指向改目录结构体的指针)、失败(NULL)

函数参数:

  • 支持相对路径和绝对路径两种方式

closedir函数

关闭打开的目录

函数原型:

  • int *closedir(DIR *dirp);

返回值:

  • 成功(0)、失败(-1 errno)

readdir函数

读取目录(库函数)

函数原型:

  • struct dirent *readdir(DIR *dirp);

返回值:

  • 成功(指向改目录结构体的指针)、失败(NULL)

注意:读取结束时也返回NULL值,所以应借助errno进一步区分

struct dirent {
inode;
char d_name[256];
}

递归遍历目录

查询指定目录,递归列出目录中文件,同时显示文件大小

// 判断命令行参数, 获取用户要查询的目录名
// 判断用户指定的是否为目录
// 读目录 opendir() readdir() closedir()
#include <unistd.h>
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dirent.h>

#define PATH_LEN 256

void fetchdir(const char *dir, void (*fcn)(char *)) {
// 该函数用于已被判定为目录
char name[PATH_LEN];
struct dirent *sdp;
DIR *dp;

if((dp = opendir(dir)) == NULL) {
// 打开目录失败
fprintf(stderr, "fetchdir: can't open %s\n", dir);
return ;
}

while((sdp = readdir(dp)) != NULL) {
if(strcmp(sdp->d_name, ".") == 0 || strcmp(sdp->d_name, "..") == 0) {
// 防止出现无限递归
continue;
}
if(strlen(dir) + strlen(sdp->d_name) + 2 > sizeof(name)) {
fprintf(stderr, "fetchdir: name %s %s too long\n", dir, sdp->d_name);
} else {
sprintf(name, "%s/%s", dir, sdp->d_name);
(*fcn)(name);
}
}
closedir(dp);
}

void isfile(char *name) {
// 处理目录 文件
struct stat sbuf;
if(stat(name, &sbuf) == -1) {
// 文件名无效
fprintf(stderr, "isfile: can't access %s\n", name);
exit(1);
}

if((sbuf.st_mode & S_IFMT) == S_IFDIR) {
// 判断是否为目录
fetchdir(name, isfile);
}
printf("%8ld %s\n", sbuf.st_size, name); // 不是目录, 则是普通文件, 直接打印文件名
}

int main(int argc, char *argv[]) {
if(argc == 1) {
isfile(".");
} else {
while(--argc > 0) {
// 可一次查询多个目录
// 循环调用该函数处理各个命令行传入的目录
isfile(*++argv);
}
}

return 0;
}

dup函数和dup2函数

重定向

函数原型:

  • int dup(int oldfd);
  • int dup2(ind oldfd, int newfd);

返回值:

  • 成功(一个新文件的描述符)、失败(-1 errno)

进程

进程相关概念

程序和进程

程序:是指编译好的二进制文件,在磁盘上,不占用系统资源(CPU、内存、打开的文件、设备、锁…)

进程:是一个抽象概念,与操作系统原理联系紧密。进程是活跃的程序,占用系统资源,在内存中执行(程序运行起来产生进程)

并发

并发:在操作系统中,一个时间段中有多个进程都处于已启动运行到运行完毕之间状态。但任一个时刻点上仍只有一个进程在运行

单道程序设计

所有进程一个一个排队执行。若A阻塞,B只能等待,及时CPU处于空闲状态。而在人机交互时阻塞的出现是必然的。所有这种模型在系统资源利用上极其不合理,在计算机发展史上存在不久就淘汰了

多道程序设计

在计算机内存中同时存放几道相互独立的程序,它们在管理程序控制之下,相互穿插的运行。多道程序设计必须有硬件基础作为保证

时钟中断:是多道程序设计模型的理论基础。并发时,任意进程在执行期间都不希望放弃CPU,因此系统需要一种强制让进程让出CPU资源的手段。时钟中断有硬件基础作为保障,对进程而言不可抗拒。操作系统中的中断处理函数负责调度程序执行

在多道程序设计模型中,多个进程轮流使用CPU(分时复用CPU资源)。而当下常见CPU为纳秒级,1秒内可以执行大约10亿条指令。由于人眼的反应速度为毫秒级,进程可以看似同时进行

CPU和MMU

MMU:(位于CPU内)

  • 虚拟内存与物理内存的映射
  • 修改内存访问级别

进程控制块PCB

我们知道,每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux内核的进程控制块是task_struct结构体

  • 进程id:系统中每个进程有唯一id,在C语言中用pid_t类型表示,其实就是一个非负整数
  • 进程的状态:初始、就绪、运行、挂起、停止等
  • 进程切换时需要保存和恢复一些CPU寄存器
  • 描述虚拟地址空间
  • 描述控制终端的信息
  • 当前工作目录(Current Working Directory)
  • umask掩码
  • 文件描述符表,包含很多指向file结构体的指针
  • 用户id和组id
  • 会话(Session)和进程组
  • 进程可以使用的资源上线(Resource Limit)

环境变量

环境变量指在操作系统中用来指定操作系统运行环境的一些参数。通常具备以下特征:

  • 字符串
  • 有统一的格式:名 = 值
  • 值用来描述进程环境信息

存储形式:与命令行参数类似。char *[]数组,数组名 environ,内部存储字符串,NULL作为哨兵结尾

使用形式:与命令行参数类似

加载位置:与命令行参数类似。位于用户区,高于stack的起始位置

环境变量表:须声明环境变量。extern char **environ

进程控制

fork函数

函数原型:

  • pid_t fork(void);

返回值:

  • 两个返回值(子进程返回0,父进程返回子进程id)
#include <unistd.h>
#include <stdio.h>

int main() {
printf("before fork-1");
printf("before fork-2");
printf("before fork-3");
printf("before fork-4");

pid_t pid = fork();
if(pid == -1) {
perror("fork error");
return 1;
} else if(pid == 0) {
printf("child is created\n");
} else if(pid > 0) {
printf("parent precess: my child is %d\n", pid);
}

printf("======end of file======");

return 0;
}

getpid函数和getppid函数

函数原型:

  • pid_t getpid(void);
  • pid_t getppid(void);

返回值:

  • 进程的id
#include <unistd.h>
#include <stdio.h>

int main() {
printf("before fork-1\n");
printf("before fork-2\n");
printf("before fork-3\n");
printf("before fork-4\n");

pid_t pid = fork();
if(pid == -1) {
perror("fork error");
return 1;
} else if(pid == 0) {
printf("child pid: %d\t parent pid: %d\n", getpid(), getppid());
} else if(pid > 0) {
printf("child pid: %d\t parent pid: %d\n", pid, getpid());
}

printf("======end of file======\n");

return 0;
}

循环创建子进程

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

int main() {
int i;
pid_t pid;

for(i = 0; i < 5; i++) {
if(fork() == 0) {
break;
}
}
if(i == 5) {
printf("I'm parent\n");
} else {
printf("I'm child %d\n", i);
}

return 0;
}

getuid函数和getgid函数

函数原型:

  • uid_t getuid(void);
  • uid_t geteuid(void);
  • gid_t getgid(void);
  • gid_t getegid(void);

返回值:

  • uid_t(当前进程实际用户id、当前进程有效用户id)、gid_t(当前进程使用用户组id、当前进程有效用户组id)

进程共享

父子进程之间在fork后,有哪些异同

刚fork之后:

  • 相同点:全局变量、.data、.text、栈、堆、环境变量、用户ID、宿主目录、进程工作目录、信号处理方式…
  • 不同点:进程ID、fork返回值、父进程ID、进程运行时间、闹钟(定时器)、未决信号处理方式

父子进程间遵循读时共享写时复制的原则。这样设计,无论子进程执行父进程的逻辑还是执行自己的逻辑都能节省内存开销

父子进程共享:文件描述符(打开文件的结构体)、mmap建立的映射区(进程间通信详解)

exec函数

fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同代卖分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程的启动历程开始执行。调用exec并不创建新的进程,所以调用exec前后该进程的id并未改变

将当前进程的.text、.data替换为所要加载的程序的.text、.data,然后让进程从新的.text第一条执行开始执行,但进程id不变,换核不换壳

函数原型:

  • int execl(const char *path, const char *arg, ...);
  • int execlp(const char *file, const char *arg, ...);
  • int execle(const char *path, const char *arg, ..., char *const envp[]);
  • int execv(const char *path, char *const argv[]);
  • int execvp(const char *file, char *const argv[]);
  • int execve(const char *path, char *const argv[], char *const envp[]);

返回值:

  • 只有在出错时返回-1

execl:加载一个进程,借助路径+程序名(一般用来执行自己编译好的程序)

execlp:加载一个进程,借助PATH环境变量(一般用来执行系统程序,如:ls)

回收子进程

孤儿进程:父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程称为init进程,称为init进程领养孤儿进程(init进程可以理解为进程孤儿院)

僵尸进程:进程终止,父进程尚未回收子进程残留在内存中资源资源(PCB),变成僵尸进程

注意:僵尸进程是不能使用kill命令清除掉的。因为kill命令指示用来终止进程的,而僵尸进程已经终止

wait函数

一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果时正常终止则保存着退出状态,如果时异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用wait或waitpid获取这些信息,然后彻底清除这个进程。我们知道一个进程的退出状态可以在shell中用特殊变量$?查看,因为shell是他的父进程,当他终止时shell调用wait或waitpid得到它的退出状态同时彻底清除掉这个进程

父进程用wait函数可以回收子进程终止信息,wait函数的三个功能:

  • 阻塞等待子进程退出
  • 回收子进程残留资源
  • 获取子进程结束状态(退出原因)

函数原型:

  • pid_t wait(int *status);

返回值:

  • 成功(清理掉的子进程id)、失败(-1)
#include <unistd.h>
#include <stdio.h>
#include <sys/wait.h>

int main() {
pid_t pid, wpid;
int status; // 可以调用宏函数处理status

pid = fork();
if(pid == 0) {
// 子进程
printf("child, my id = %d, going to sleep 10s\n", getpid());
sleep(10);
printf("child die\n");
} else if(pid > 0) {
// 父进程
wpid = wait(&status);
if(wpid == -1) {
perror("wait error");
exit(1);
}
printf("parent wait finish: %d\n", wpid);
} else {
perror("fork");
return 1;
}

return 0;
}
  • WIFEXITED(status)返回值非零,程序正常结束

    WEXITSTATUS(status)如果上宏为真,使用此宏,获取进程退出状态

  • WIFSIGNALED(status)返回值非零,程序异常终止

    WTERMSIG(status)如果上宏为真,使用此宏,获取进程终止的那个信号编号

  • WIFSTOPPED(status)返回值非零,进程处于暂停状态

    WSTOPSIG(status)如果上宏为真,使用此宏,获取进程暂停的那个信号的编号

    WIFCONTINUED(status)为真,进程暂停后继续运行

waitpid函数

作用同wait,但可指定pid进程清理,可以不阻塞

函数原型:

  • pid_t waitpid(pid_t pid, int *status, int options);

返回值:

  • 0(待回收的子进程pid)、-1(任意子进程)、0(同组子进程)

注意:一次wait和waitpid调用只能清理一个子进程,清理多个子进程应使用循环

IPC方法

Linux环境下,进程地址空间相互独立,每个进程各自有不同的用户地址空间。任何一个进程的全局变量在另一个进程中都看不到,所以进程和进程之间不能相互访问,要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷贝到内核缓冲区,进程2从内核缓冲器把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)

在进程间完成数据传递需要借助操作系统提供特殊的方法,如:文件、管道、信号、共享内存、消息队列、套接字、命名管道等。随着计算机的蓬勃发展。一些方法由于自身设计缺陷被淘汰或弃用。现金常用的进程间通信方式有:

  • 管道(使用最简单)
  • 信号(开销最小)
  • 共享映射区(无血缘关系)
  • 本地套接字(最稳定)

管道

管道的概念

管道是一种最基本的IPC机制,作用于有血缘关系的进程之间,完成数据传递。调用pipe系统函数即可创建一个管道,管道有如下特性:

  • 其本质是一个为文件(实为内核缓冲区)
  • 由两个文件描述符应用,一个表示读段,一个表示写端
  • 规定数据从管道的写端流入管道,从读端流出

管道的原理:管道实为内核使用环形队列机制,借助内核缓冲区(4k)实现

管道的局限性:

  • 数据不能进程自己写,自己读
  • 管道中数据不可反复读取。一旦读走,管道中不在存在
  • 采用半双工通信方式,数据只能在单方向上流动
  • 只能在有公共祖先的进程间使用管道

pipe函数

函数原型:

  • int pipe(int pipefd[2]

返回值:

  • 成功(0)、失败(-1,errno)

函数调用成功返回r/w两个文件描述符。无需open,但需手动close。规定:fd[0](r)、fd[1](w)就像0对应标准输入,1对应标准输出一样。向管道文件读写数据及时是在读写内核缓冲区

管道创建成功以后,创建该管道的进程(父进程)同时掌握着管道的读端和写端

管道的读写行为

使用管道需要注意一下4中情况(假设都是阻塞I/O,没有设置O_NONBLOCK标志):

  • 如果所有指向管道写端的文件描述符都关闭了(管道写端引用计数为0),而仍然有进程从管道的读端读数据,那么管道中剩余的数据都被读取后,再次read会返回0,就像读到文件末尾一样
  • 如果指向管道写端的文件描述符没关闭(管道写端引用计数大于0),而持有管道写端的进程灭有向管道中写数据,这是才有进程从管道读端读数据,那么管道中剩余的数据被读取后,再次read会阻塞,直到管道中有数据可对才读取数据并返回
  • 如果所有指向管道读端的文件描述符都关闭了(管道读端引用计数为0),这时有进程向管道的写端write,那么该进程会收到信号SIGPIPE,通常会导致进程异常终止。当然也可以对SIGPIPE信号实施捕捉,不终止进程
  • 如果指向管道写端的文件描述符没关闭(管道写端引用计数大于0),而持有管道读端的进程也么有从管道中读数据,这时有进程向管道写端写数据,那么管道被写满时再次write会阻塞,直到管道中有空位置了才写入数据并返回

管道缓冲区大小

可以使用ulimit -a命令来查看当前系统中创建管道文件所对应的内核缓冲区大小

也可以使用fpathconf函数,借助参数选项来查看

管道的优劣

优点:简单,相比信号,套接字实现进程间通信,简单很多

缺点:

  • 只能单向通信,双向通信需建立两个管道
  • 只能用于父子、兄弟进程(有共同祖先)间通信。该问题后来使用fifo有名管道解决
// 父子进程间通信
#include <stdio.h>
#include <unistd.h>
#include <errno.h>

void sys_err(const char *str) {
perror(str);
exit(1);
}

int main() {
int fd[2];
int ret;
pid_t pid;

// 父进程创建一个管道, 持有管道的读端和写端
ret = pipe(fd);
if(ret == -1) {
sys_err("pipe error\n");
}

// 子进程同样持有管道的读端和写端
pid = fork();
if(pid == -1) {
sys_err("fork error\n");
} else if(pid > 0) {
// 父进程关闭写端
close(fd[1]);
// 重定向stdin到管道读端
dup2(fd[0], STDIN_FILENO);
// 执行wc -l
execlp("wc", "wc", "-l", NULL);
sys_err("execlp wc error\n");
} else if(pid == 0) {
// 子进程关闭读端
close(fd[0]);
// 重定向stdout到管道写端
dup2(fd[1], STDOUT_FILENO);
// 执行ls
execlp("ls", "ls", NULL);
sys_err("execlp ls error\n");
}

return 0;
}
// 兄弟进程间通信
#include <stdio.h>
#include <unistd.h>
#include <errno.h>

void sys_err(const char *str) {
perror(str);
exit(1);
}

int main() {
int fd[2];
int ret, i;
pid_t pid;

// 父进程创建一个管道, 持有管道的读端和写端
ret = pipe(fd);
if(ret == -1) {
sys_err("pipe error\n");
}

for(i = 0; i < 2; i++) {
pid = fork();
if(pid == -1) {
sys_err("fork error\n");
}
if(pid == 0) {
// 子进程出口
break;
}
}

if(i == 2) {
// 父进程不参与管道使用
close(fd[0]);
close(fd[1]);

// 回收子进程
wait(NULL);
wait(NULL);
} else if(i == 0) {
// 兄进程
close(fd[0]);
// 重定向stdin到管道读端
dup2(fd[1], STDOUT_FILENO);
// 执行ls
execlp("ls", "ls", NULL);
sys_err("execlp ls error\n");
} else if(i == 1) {
// 弟进程
close(fd[1]);
// 重定向stdout到管道写端
dup2(fd[0], STDIN_FILENO);
// 执行ws -l
execlp("ws", "ws", "-l", NULL);
sys_err("execlp ws error\n");
}

return 0;
}

FIFO

FIFO常被称为命名管道,以区分管道(pipe)。管道(pipe)只能用于”有血缘关系”的进程间。但通过FIFO,不相关的进程也能交换数据

FIFO是Linux基础文件类型中的一种。但FIFO文件在磁盘上没有数据块,仅仅用来标识内核中一条通道。各进程可以打开这个文件进行read/write,实际上是在读写内核通道,这样就实现了进程间通信

创建方式:

  • 命令:mkfifo 管道名
  • int mkfifo(const char *pathname, mode_t mode);

返回值:

  • 成功(0)、失败(-1)

一旦使用mkfifo创建一个FIFO,就可以使用open打开它,常见的文件I/O函数都可以用于fifo

// 无血缘关系进程间通信
// 创建一个管道文件mkfifo
#include <stdio.h>
#include <unistd.h>
#include <errno.h>

void sys_err(const char *str) {
perror(str);
exit(1);
}

int main(int argc, char *argv[]) {
int fd, len;
char buf[4096];

if(argc < 2) {
printf("./a.out fifoname\n");
return -1;
}

fd = open(argv[1], O_RDONLY);
if(fd < 0) {
sys_err("open error");
}

while(1) {
len = read(fd, buf, sizeof(buf));
write(STDOUT_FILENO, buf, len);
sleep(3);
}

close(fd);

return 0;
}
// 无血缘关系进程间通信
// 创建一个管道文件mkfifo
#include <stdio.h>
#include <unistd.h>
#include <errno.h>

void sys_err(const char *str) {
perror(str);
exit(1);
}

int main(int argc, char *argv[]) {
int fd, i;
char buf[4096];

if(argc < 2) {
printf("Enter like this: ./a.out fifoname\n");
return -1;
}

fd = open(argv[1], O_WRONLY);
if(fd < 0) {
sys_err("open error");
}

i = 0;
while(1) {
sprintf(buf, "hello itcast %d\n", i++);
write(STDOUT_FILENO, buf, len);
sleep(1);
}

close(fd);

return 0;
}

文件存储映射

文件进程间通信

使用文件也可以完成IPC,fork后父子进程共享文件描述符,也就共享文件打开的文件

存储映射I/O

存储映射I/O(Memory-mapped I/O)是一个磁盘文件与存储空间中的一个缓冲区想映射。于是当从缓冲区中取出数据,就相当于读文件中的相应字节。与此类事,将数据存入缓冲区,则相应的字节就自动写入文件。这样,就可以不使用read和write函数的情况下,使用地址(指针)完成I/O操作

mmap函数

创建共享内存映射

函数原型:

  • void *mmap(void *adrr, size_t length, int prot, int flags, int fd, off_t offset);

返回值:

  • 成功(返回创建的映射首地址)、失败(MAP_FAILED宏)

函数参数:

  • addr:建立映射区的首地址,由Linux内核指定。使用时

  • length:欲创建映射区的大小

  • prot:映射区权限:PROT_READ、PROT_WRITE、PROT_READ|PROT_WRITE

  • flags:标志位参数(常用于设定更新物理区域、设置共享、创建匿名映射区)

    MAP_SHARED:会将映射区所做的操作反映到物理设备(磁盘)上

    MAP_PRIVATE:映射区所做的修改不会反应到物理设备

  • fd:用于创建共享内存映射区的文件描述符

  • offset:偏移位置(默认0表示映射全部文件,偏移位置需要是4k整数倍)

munmap函数

释放内存映射区

函数原型:

  • int munmap(void addr, size_t length);

mmap注意事项

  • 创建映射区的过程中,隐含着一次对映射文件的读操作
  • 当MAP_SHARED时,要求:映射区的权限 <= 文件的打开权限(出于对映射的保护)。而MAP_PRIVATE则无所谓,因为mmap中的权限是对内存的限制
  • 映射区的释放与文件关闭无关。只要映射建立成功,文件可以立即关闭
  • 特别注意,当映射文件大小为0时,不能创建映射区。所以:用于映射的文件必须要有实际的大小。mmap使用时常常会出现总线错误,通常是由于共享文件存储空间大小引起的
  • munmap传入地址一定是mmap的返回地址,杜绝使用++操作
  • 文件偏移量必须为4k整数倍
  • mmap创建映射区出错概率非常高,一定要检查返回值,确保映射区建立成功再进行后续操作

mmap父子进程通信

父子等有血缘关系的进程之间也可以通过mmap建立的映射区来完成数据通信。但相应的要在创建映射区时候指定对应的标志位参数flags:

  • MAP_PRIVATE(私有映射):父子进程各自独占映射区
  • MAP_SHARED(共享映射):父子进程共享映射区

mmap无血缘关系进程间通信

实质上mmap是内核借助文件帮我们创建了一个映射区,多个进程之间利用该映射区完成数据传递。由于内核空间多进程共享,因此无血缘关系的进程间也可以使用mmap来完成通信。只要设置相应的标志位参数flags即可。实现共享可以使用MAP_SHARED

一个进程写入一个进程读出,可以读出相同内容

匿名映射

在上述映射中我们发现,使用映射区来完成文件读写操作十分方便,父子进程间通信也比较容易。但缺陷是,每次创建映射区一定要依赖一个文件才能实现。通常为了建立映射区要open一个temp文件,创建好了再unlink、close比较麻烦

可以直接使用匿名映射来代替,其实Linux系统提供给我们提供了创建匿名映射区的方法,无需依赖一个文件即可创建映射区

只能用于有血缘关系间进程通信

信号

头文件:<signal.h>

信号的概念

信号在我们生活中随处可见,其有简单、不能携带大量信息、满足某个特定条件才能发送等特性

信号是信息的载体,Linux/UNIX环境下,古老、经典的通信方式依然是主要的通信手段

UNIX早期版本提供了信号量机制,但不可靠信号可能丢失。Berkeley和AT&T都对信号模型做了更改,增加了可靠信号机制,但彼此不兼容。POSIX.1对可靠信号例程进行了标准化

信号的机制

A给B发送信号,B收到信号之前执行自己的代码,收到信号后,不管执行到程序的什么位置,都要暂停运行,去处理信号,处理完毕之后再鸡血执行。与硬件中断类似(异步模式)。但信号是软件层面上实现的中断,早起常被称为”软中断”

信号的特质:由于信号是通过软件方法实现,其实现手段导致信号有很强的延时性。但对于用户来说,这个延迟时间非常短,不易察觉

每个进程收到的所有信号都是由内核负责发送的

与信号相关的事件和状态

产生信号:

  • 按键产生:Ctrl+c、Ctrl+z
  • 系统调用产生:kill、raise、abort
  • 软件条件产生:定时器alarm
  • 硬件异常产生:非法访问内存(段错误)、除0(浮点数例外)、内存对齐出错(总线出错)
  • 命令产生:kill命令

递达:递达并且到达进程

未决:产生和递达之间状态。主要由于阻塞(屏蔽)导致该状态

信号的处理方式:

  • 执行默认动作
  • 忽略(丢弃)
  • 捕捉(调用户处理函数)

Linux内核的进程控制块PCB是一个结构体,task_struct,除了包含进程id、状态、工作目录、用户id、组id、文件描述符表,还包含了信号相关的信息,主要指阻塞信号集和未决信号集

阻塞信号集(信号屏蔽字):将某些信号加入集合,对他们设置屏蔽,当屏蔽x信号后在收到该信号,该信号的处理将推后(解除屏蔽后)

未决信号集:

  • 信号产生,未决信号集中描述该信号的位理科翻转为1,表信号处于未决状态。当信号被处理对应位翻转为0这一时刻往往非常短暂
  • 信号产生后由于某些原因(主要是阻塞)不能抵达。这类信号的集合称之为未决信号集。在屏蔽解除前信号一直处于未决状态

信号的编号

可以使用kill -l命令查看当前系统可用的信号有那些

不存在编号为0的信号。其中1-31号信号称之为常规信号(也叫普通信号或标准信号),34-64称之为实时信号驱动变成和硬件相关,名字上区别并不大。而前32个名字各不相同

信号四要素

与变量三要素类似的,每个信号也有其必备的四要素:编号、名称、事件、默认处理动作

不同操作系统定义了不同的系统信号。因此有些信号出现在UNIX系统内也出现在Linux中,而有的信号出现在FreeBSD或MacOS中却没有出现在Linux下

默认动作:

  • Term:终止进程
  • Ign:忽略信号(默认即时对该种信号忽略操作)
  • Core:终止进程,生成Core文件(查验进程死亡原因,用于GDB调试)
  • Stop:停止(暂停)进程
  • Cont:继续运行进程

终端按键产生信号

Ctrl+c:SIGINT(终止/终端)

Ctrl+z:SIGTSTP(暂停/停止)

Ctrl+\:SIGQUIT(退出)

硬件异常产生信号

除0操作:SIGFPE(浮点数例外)

非法访问内存:SIGSEGV(段错误)

总线错误:SIGBUS

kill命令和kill函数

函数原型:

  • int kill(pid_t pid, int sig);

返回值:

  • 成功(0)、失败(-1)

pid > 0:杀指定进程

pid = 0:杀跟调用kill函数那个进程处于同一进程组的进程

pid < -1:取绝对值,发送信号给该绝对值所对应的进程组

pid = -1:发送信号给有权限的所有进程

alarm函数

设置定时器(闹钟)。在指定seconds后,内核会给当前进程发送SIGALRM信号。进程收到该信号,默认动作终止,每个进程都有且只有唯一个定时器

函数原型:

  • unsigned int alarm(unsigned int seconds);

返回值:

  • 0回剩余秒数,无失败

定时,与进程状态无关(自然定时法)就绪、运行、挂起(阻塞、暂停)、终止、僵尸…无论进程处于何种状态,alarm都计时

实际执行时间 = 系统时间 + 用户时间 + 等待时间

setitimer函数

设置定时器(闹钟),可代替alarm函数。精度微秒us,可以实现周期定时

函数原型:

  • int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);

返回值:

  • 成功(0)、失败(-1,errno)

函数参数:

  • which(指定定时方式)、new_value(定时秒数)、old_value(传出参数,上次定时剩余时间)
    • 自然定时:ITIMER_REAL
    • 虚拟空间计时(用户空间):ITIMER_VIRTUAL
    • 运行时计时(用户 + 内核):ITIMER_PROF

it_interval:用来设定两次定时任务之间时间间隔

it_value:定时的时长

struct itimeval {
struct timeval it_interval;
struct timeval it_value;
}

struct timeval {
time_t tv_sec;
suseconds_t tv_usec;
}

信号集操作函数

内核通过读取未决信号集来判断信号是否应被处理。信号屏蔽字mask可以影响未决信号集。而我们可以在应用程序中自定义set来改变mask,以达到屏蔽指定信号的目的

信号集设定

函数原型:

  • int sigemptyset(sigset_t *set);将某个信号集清0
  • int sigfillset(sigset_t *set);将某个信号集置1
  • int sigaddset(sigset_t *set, int signum);将某个信号加入信号集
  • int sigdelset(sigset_t *set, int signum);将某个信号清出信号集
  • int sigidmember(const sigset_t *set, int signum);判断某个信号是否在信号集中

返回值:

  • 成功(0)、失败(-1)
  • 在集合中(1)、不在集合中(-1)

sigprocmask函数

用来屏蔽信号、解除信号也使用该函数。其本质读取或修改进程的信号屏蔽字(PCB)

注意:屏蔽信号只是将信号处理延后执行(延至解除屏蔽),而忽略表示将信号丢弃处理

函数原型:

  • int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

函数返回值:

  • 成功(0)、失败(-1,errno)

函数参数:

  • set(传入参数,是一个位图)、oldset(传出参数,保存旧的信号屏蔽集)、how(参数取值,假设当前的信号屏蔽字为mask)

SIG_BLOCK:当how设置为此值,set表示需要屏蔽的信号。相当于mask = mask | set

SIG_UNBLOCK:当how设置为此值,set表示需要解除屏蔽的信号。相当于mask = mask &~ set

SIG_SETMASK:当how设置为此值,set表示用于代替原始屏蔽及新的屏蔽集。相当于mask = set

sigpending函数

读取当前进程的未决信号集

函数原型:

  • int sigpending(sigset_t *set);

返回值:

  • 成功(0)、失败(-1,errno)

signal函数

注册一个信号捕捉函数

函数原型:

  • sighandler_t signal(int signum, sighandler_t handler);

该函数有ANSI定义,由于历史原因在不同版本的UNIX和不同版本的Linux中可能有不同的行为。因此应该尽量避免

sigaction函数

修改信号处理动作(通常在Linux用来注册一个信号的捕捉函数)

函数原型:

  • int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

返回值:

  • 成功(0)、失败(-1,errno)

函数参数:

  • act(传入参数,新的处理方式)、oldact(传出参数,旧的处理方式)
// struct sigaction结构体
struct sigaction {
void (*sa_handler)(int); // 指定信号捕捉后的处理函数
void (*sa_sigaction)(int, siginfo_t, void *); // 很少使用
sigset_t sa_maske; // 调用信号处理函数时, 所要屏蔽的信号集合(屏蔽信号字)
int sa_flags; // 通常设置为0, 表示用默认属性
void (*sa_restorer)(void); // 弃用
};

信号捕捉特性

  • 进程运行时,默认PCB中有一个信号屏蔽字假定为mask,它决定了进程自动屏蔽哪些信号。当注册了某个信号捕捉函数,捕捉到该信号以后,要调用该函数。而该函数有可能执行很长时间,在这期间所屏蔽的信号不由mask来指定。而是用sa_mask来指定。调用完信号处理函数,再恢复为mask
  • XXX信号捕捉函数执行期间,XXX信号自动被屏蔽
  • 阻塞的常规信号不支持排队,产生多次只记录一次

SIGCHLD信号

信号产生条件:

  • 子进程终止时
  • 子进程接收到SIGSTOP信号停止时
  • 子进程处在停止态,接受到SIGCONT后唤醒时

借助SIGCHLE信号回收子进程:子进程结束运行,其父进程会受到SIGCHLD信号。该信号的默认处理动作时忽略。可以捕捉该信号,在捕捉函数中完成子进程状态的回收

中断系统调用

系统调用可分为两类:慢速系统调用和其他系统调用

  • 慢速系统调用:可能会使进程永远阻塞的一类。如果在阻塞期间收到一个信号,该系统调用就该被中断,不在继续执行(早起);也可以设定系统调用是否重启。如:read、write、pause、wait…
  • 其他系统调用:getpid、getppid、fork…

结合pause,回顾慢速系统调用:

慢速系统调用被中断的相关行为,实际上就是pause的行为,如:read

  • 想中断pause,信号不能被屏蔽
  • 信号的处理方式必须是捕捉(默认、忽略都不可以)
  • 中断后返回-1,设置errno为EINTR(表示”被信号中断”)

可修改sa_flags参数来设置被信号中断后系统调用是否重启。SA_INTERRURT不重启、SA_RESTART重启

进程组和会话

概念和特性

进程组,也称之为作业。BSD与1980年前后向UNIX中增加的一个新特性。代表一个或多个进程的集合。每个进程都属于一个进程组。在waitpid函数和kill函数的参数中都曾使用到。操作系统设计的进程组的概念是为了简化对多个进程的管理

当父进程创建子进程时,默认子进程与父进程属于同一进程组。进程组ID == 第一个进程ID(组长进程)。所以,组长进程标识:其进程组ID == 其进程ID

可以使用kill -SIGKILL -进程组ID(负的)来讲整个进程组内的进程全部杀死

组长进程可以创建一个进程组,创建该进程组中的进程,然后终止。只要进程组中有一个进程存在,进程就存在,与组长进程是否终止无关

进程组生存期:进程组创建到最后一个进程离开(终止或转移到另一个进程组)

一个进程可以为自己或子进程设置进程组ID

创建会话

创建一个会话需要注意以下6点注意事项:

  • 调用进程不能是进程组组长,该进程变成新会话收进程(sesssion header)
  • 该进程成为一个新进程组的组长进场
  • 需要root权限(Ubuntu不需要)
  • 新会话丢弃原有的控制终端,该会话没有控制终端
  • 该调用进程是组长进程,则出错返回
  • 建立新会话时,先调fork,父进程终止,子进程调用setsid()

getsid函数

获取进程所属的会话ID

函数原型:

  • pid_t getsid(pid_t pid);

返回值:

  • 成功(返回调用进程的会话ID)、失败(-1,errno)

pid为0表示查看当前进程session ID

ps ajx命令查看系统中的进程。参数a表示不仅列当前用户的进程,也列出所有其他用户的进程,参数x表示不仅列有控制终端的进程,也列出所有无控制终端的进程,参数j表示列出与作业控制相关的信息

组长进程不能成为新会话首进程,新会话首进程必定会成为组长进程

setsid函数

创建一个会话,并以自己的ID设置进程组ID,同时也是新会话的ID

函数原型:

  • pid_t setsid(void);

返回值:

  • 成功(返回调用进程的会话ID)、失败(-1,errno)

调用了setsid函数的进程,既是新的会长,也是新的组长

守护进程

Daemon(精灵)进程,是Linux中的后台服务进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。一般采用以d结尾的名字

Linux后台的一些系统服务程序,没有控制终端,不能直接和用户交互。不受用户登录、注销的影响,一直在运行着,它们都是守护进程。如:预读入缓输出机制的实现、ftp服务器、nfs服务器等

创建守护进程,最关键的一步是调用setsid函数创建一个新的Session,并成为Session Leader

创建守护进程模型

  • 创建子进程,父进程退出

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

  • 在子进程中创建新的会话

    setsid()函数

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

  • 改变当前目录位置

    chdir()函数

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

    也可以换成其他路径

  • 重设文件权限掩码

    umask()函数

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

    增加守护进程灵活性

  • 关闭文件描述符

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

  • 开始执行守护进程核心工作守护进程退出处理程序模型

注意:当守护进程在后台运行时,它不应该与任何终端相关联,因为它不再与用户交互。如果守护进程仍然与终端相关联,则它可能会收到来自终端的信号,或者在终端关闭时被终止。为了避免这种情况,守护进程必须关闭所有与终端相关的文件描述符

线程

头文件:<pthread.h>

什么是线程

LWP:light weight process轻量级的线程,本质仍是进程(在Linux环境下)

进程:独立地址空间,又有PCB

线程:有独立的PCB,但没有独立的地址空间(共享)

区别:在于是否共享空间

Linux下:

  • 线程:最小的执行单位
  • 进程:最小分配资源单位,可看成是只有一个线程的进程

Linux内核线程实现原理

类UNIX系统中,早起是没有”线程”概念的,80年代才引入,借助进程机制实现出了线程的概念。因此在这类系统中,进程和线程关系密切

  • 轻量级进程(light-weight process)也有PCB,创建线程使用的底层函数和进程一样,都是clone
  • 从内核里看进程和线程是一样的,都有各自不同的PCB,但是PCB中指向内存资源的三级页表是相同的
  • 进程可以蜕变成线程
  • 线程可看做寄存器和栈的集合

线程共享资源:

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

线程非共享资源:

  • 线程ID
  • 处理器现场和栈指针(内核栈)
  • 独立的栈空间(用户空间栈)
  • errno变量
  • 信号屏蔽字
  • 调度优先级

线程优点:调高程序并发性、开销小、数据通信共享方便

线程缺点:库函数不稳定、调试编写困难、对信号支持不好

pthread_self函数

获取线程ID,其作用对应进程中的getpid()函数

函数原型:

  • pthread_t pthread_self(void);

返回值:

  • 成功(0)、失败(无)

线程ID:pthread_t类型,在Linux下无符号整数(%lu),其他系统中可能是结构体实现

线程ID是进程内部识别标志(两进程间线程ID允许相同)

注意:不应使用全局变量pthread_t tid在子线程中通过pthread_create传出参数来获取线程ID,而应使用pthread_self

pthread_create函数

创建一个新线程,其作用对应进程中fork()函数

函数原型:

  • int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);

返回值:

  • 成功(0)、失败(errno)

函数参数:

  • *thread:传出参数,保存系统为我们分配好的线程ID
  • *attr:通常传NULL,表示使用线程默认属性。若想使用具体属性也可以修改参数
  • *(*start_routine)(void *):函数指针,指向线程主函数(线程体),该函数运行结束时线程结束
  • *arg:线程主函数执行期间所使用的参数

pthread_exit函数

将单个线程退出

函数原型:

  • void pthread_exit(void *retval);

函数参数:

  • retval(表示线程退出状态,通常传NULL)

线程中禁止使用exit函数,会导致进程所有线程全部退出

在不添加sleep控制输出顺序的情况下,pthread_create再循环中几乎瞬间创建了5个线程,但只有第一个线程有机会输出(取决于内核调度),如果第三个线程执行了exit将整个进程退出了,所以全部线程退出了

在多线程环境中,尽量少使用或不使用exit函数,取而代之使用pthread_exit函数将单个线程退出。任何线程里exit导致进程退出,其他线程未工作结束,主控线程退出时不能return或exit

注意:pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配,不能在线程函数的栈上分配,因为当其他线程得到这个返回指针时线程函数已经退出了

pthread_join函数

阻塞等待线程退出,获取线程退出状态,其作用对应进程中的waitpid()函数

函数原型:

  • int pthread_join(pthread_t thread, void **retval);

返回值:

  • 成功(0)、失败(errno)

函数参数:

  • thread(线程ID)、retval(存储线程结束状态)

pthread_cancel函数

杀死(取消)线程,其作用对应进程中kill()函数

函数原型:

  • int pthread_cancel(pthread_t thread);

返回值:

  • 成功(0)、失败(errno)

注意:线程的取消并不是实时的,而是有一定延迟的。需要等待线程到达某个取消点(检查点)

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

可粗略认为一个系统调用(进入内核)即为一个取消点。如果线程中没有取消点,可以通过调用pthread_testcancel函数自行设置一个取消点

被取消的线程退出值定义在Linux的pthread库中,常熟PTHREAD_CANCELED的值是-1.可在头文件pthread.h中找到,#define PTHREAD_CANCELED((void *)-1),因此当我们对一个已经取消的现场跟使用pthread_join回收时得到的值为-1

pthread_detach函数

实现线程分离,线程终止会自动清理

函数原型:

  • int pthread_detach(pthread_t thread);

返回值:

  • 成功(0)、失败(errno)

线程分离状态:指定该状态线程主动与主控线程断开关系。线程结束后,其退出状态不由其他线程获取,而直接自己自动释放,多用于网络、多线程服务器中

进程若有该机制将不会产生僵尸进程。僵尸进程的产生组要是由于进程死后,大部分资源被释放,一点残留资源仍存于系统中,导致内核认为该进程仍然存在

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

线程属性

Linux下线程的属性是可以根据实际项目需要进行设置,之前章节我们都是采用默认属性,默认属性已经可以解决大多数开发中遇到的问题,但我们对程序性能提出更高要求时需要自定义线程属性

// Linux2.2内核
typedef struct {
int etachstate; // 线程的分离状态
int schedpolicy; // 线程调度策略
struct sched_param schedparam; // 线程的调度参数
int inheritsched; // 线程的继承性
int scope; // 线程的作用域
size_t guardsize; // 线程栈末尾的警戒缓冲区大小
int stackaddr_set; // 线程的栈设置
void *stackaddr; // 线程栈的位置
size_t stacksize; // 线程栈的大小
}pthread_attr_t;

线程属性初始化

应先初始化线程属性,再pthread_create创建线程

初始化线程属性:int pthread_attr_init(pthread_attr_t *attr);

销毁线程属性所占用的资源:int pthread_attr_destroy(pthread_attr_t *attr)

返回值:

  • 成功(0)、失败(errno)

线程的分离状态

线程的分离状态决定一个线程以什么样的方式来终止自己

非分离状态:线程的默认属性是非分离状态,这种情况下,原有的线程等待创建的线程结束。只有当pthread_join()函数返回时,创建的线程才算终止,才能释放自己占用的系统资源

分离状态:分离线程没有被其他的线程所等待,自己运行结束了线程也就终止了,马上释放系统资源。应该根据自己的需要选择适当的分离状态

函数原型:

  • C(设置线程属性):int pthreat_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
  • C(获取线程属性):int pthread_attr_getdetachstate(pthread_attr_t *attr, int *detachstate);

函数参数:

  • attr:已初始化的线程属性
  • detachstate:PTHREAD_CREATE_DETACHED(分离线程)、PTHREAD_CREATE_JOINABLE(非分离线程)

线程注意事项

  • 主线程退出其他线程不退出,主线程应该调用pthread_exit
  • 避免僵尸进程(pthread_join、pthread_detach、pthread_create)
  • malloc和mmap申请的内存可以被其他线程释放
  • 应避免在多线程模型中调用fork除非马上exec,在进程中只有调用fork的线程存在,其他线程在子进程中均pthread_exit
  • 信号的复杂语义很难和多线程共存,应避免在多线程引入信号机制

同步概念

所谓同步即同时起步协调一致,不同的对象对”同步”的理解方式略有不同。如:设备同步,是指在两个设备之间规定一个共同的时间参考;数据库同步,是指让两个或多个数据库内容保持一致,或按照需求保持一致;文件同步,是指让两个或多个文件夹里的文件保持一致

编程中、通信中所说的同步与生活中的同步略有差异。”同”指协同、协助,旨意在协同步调,按照预定的先后次序运行

线程同步

指一个线程发出某一功能调用时,在没有得到结果之前,该调用不返回。同时其它线程为保证数据一致性,不能调用该功能

“同步”的目的是为了避免数据混乱,解决与时间有关的错误。实际上,不仅线程间需要同步,进程间、信号间等等都需要同步机制

因此,所有”多个控制流共同操作一个共享资源”的情况,都需要同步

数据混乱原因

  • 资源共享(独享资源则不会)
  • 调度随机(意味着数据访问会出现竞争)
  • 线程间缺乏必要的同步机制

互斥量mutex

Linux中提供一把互斥锁(也成为互斥量)

每个线程在对资源操作前都要尝试先加锁,成功加锁才能操作,操作结束解锁

资源还是共享的,线程间也还是竞争关系

互斥锁实质上是操作系统提供的一把”建议锁”(又称”协同锁”)建议程序中有多线程访问共享资源的时候使用该机制,但不是强制性限定

即使有了mutex,如果有线程不按照规则来访问数据,依然会造成数据混乱

互斥量主要函数

  • pthread_mutex_init函数(初始化)
  • pthread_mutex_destroy函数(销毁锁)
  • pthread_mutex_lock函数(加锁)
  • pthread_mutex_trylock函数(访问锁)
  • pthread_mutex_unlock函数(解锁)

pthread_mutex_t类型:结构体

pthread_mutex_t lock;(创建锁)

读写锁

  • 读写锁是”写模式加锁”时,解锁前所有对该锁加锁的线程都会被阻塞
  • 读写锁是”读模式加锁”时,如果线程以读模式对其加锁会成功;如果线程以写模式加锁会阻塞
  • 读写锁是”读模式加锁”时,既有试图以写模式加锁的线程,也有试图以读模式加锁的线程。那么读写锁会阻塞随后的读模式请求,优先满足写模式锁。读锁、写锁并阻塞,写锁优先级高

读写锁也叫共享-独占锁。当读写锁以读模式锁住时,它是以共享模式锁住的;当读写锁以写模式锁住时,它是以独占模式锁住的

读写锁非常适合于对数据结构读的次数远大于写的情况

读写锁主要函数

  • pthread_rwlock_init函数
  • pthread_rwlock_destroy函数
  • pthread_rwlock_rdlock函数
  • pthread_rwlock_wrlock函数
  • pthread_rwlock_tryrdlock函数
  • pthread_rwlock_trywrlock函数
  • pthread_rwlock_unlock函数

pthread_rwlock_t类型

死锁

  • 线程试图对同一个互斥量A加锁两次

  • 线程1拥有A锁,请求获得B锁;线程2拥有B锁,请求获得A锁

条件变量

条件变量本身不是锁,但是它也可以造成线程阻塞,通常结合锁来使用

条件变量主要函数

  • pthread_cond_init函数
  • pthread_cond_destory函数
  • pthread_cond_wait函数
  • pthread_cond_timedwait函数
  • pthread_cond_signal函数
  • pthread_cond_broadcast函数

pthread_cond_t类型

信号量

进化版的互斥锁(1->N)

由于互斥锁的粒度比较大,如果我们希望在多个线程间对某一对象的部分数据进行共享,使用互斥锁是没有办法实现的,只能将整个数据对象锁住。虽然达到了多线程操作共享数据时保证数据正确性的木器,却无形中导致线程的并发性下降。线程从并行执行,变成了串行执行。与直接使用单线程无异

信号量是相对折中的一种处理方式,既能保证同步数据不混乱,又能提高线程并发。相当于初始值为N的互斥量

信号量主要函数

  • sem_init函数
  • sem_destory函数
  • sem_wait函数
  • sem_trywait函数
  • sem_timedwait函数
  • sem_post函数

sem_t类型