【Linux】Linux系统编程入门

6,268 阅读21分钟

作者:不洗碗工作室 - Marklux
出处:marklux.cn/blog/56
版权归作者所有,转载请注明出处

文件和文件系统

文件是linux系统中最重要的抽象,大多数情况下你可以把linux系统中的任何东西都理解为文件,很多的交互操作其实都是通过文件的读写来实现的。

文件描述符

在linux内核中,文件是用一个整数来表示的,称为 文件描述符,通俗的来说,你可以理解它是文件的id(唯一标识符)

普通文件

  • 普通文件就是字节流组织的数据。
  • 文件并不是通过和文件名关联来实现的,而是通过关联索引节点来实现的,文件节点拥有文件系统为普通文件分配的唯一整数值(ino),并且存放着一些文件的相关元数据。

目录与链接

  • 正常情况下文件是通过文件名来打开的。
  • 目录是可读名称到索引编号之间的映射,名称和索引节点之间的配对称为链接
  • 可以把目录看做普通文件,只是它包含着文件名称到索引节点的映射(链接)

进程

进程是仅次于文件的抽象概念,简单的理解,进程就是正在执行的目标代码,活动的,正在运行的程序。不过在复杂情况下,进程还会包含着各种各样的数据,资源,状态甚至虚拟计算机。

你可以这么理解进程:它是竞争计算机资源的基本单位。

进程、程序与线程

  1. 程序

    程序,简单的来说就是存在磁盘上的二进制文件,是可以内核所执行的代码

  2. 进程

    当一个用户启动一个程序,将会在内存中开启一块空间,这就创造了一个进程,一个进程包含一个独一无二的PID,和执行者的权限属性参数,以及程序所需代码与相关的资料。

    进程是系统分配资源的基本单位。

    一个进程可以衍生出其他的子进程,子进程的相关权限将会沿用父进程的相关权限。

  3. 线程

    每个进程包含一个或多个线程,线程是进程内的活动单元,是负责执行代码和管理进程运行状态的抽象。

    线程是独立运行和调度的基本单位。

进程的层次结构(父进程与子进程)

在进程执行的过程中可能会衍生出其他的进程,称之为子进程,子进程拥有一个指明其父进程PID的PPID。子进程可以继承父进程的环境变量和权限参数。

于是,linux系统中就诞生了进程的层次结构——进程树。

进程树的根是第一个进程(init进程)。

过程调用的流程: fork & exec

一个进程生成子进程的过程是,系统首先复制(fork)一份父进程,生成一个暂存进程,这个暂存进程和父进程的区别是pid不一样,而且拥有一个ppid,这时候系统再去执行(exec)这个暂存进程,让他加载实际要运行的程序,最终成为一个子进程的存在。

进程的结束

当一个进程终止时,并不会立即从系统中删除,内核将在内存中保存该进程的部分内容,允许父进程查询其状态(这个被称为等待终止进程)。

当父进程确定子进程已经终止,该子进程将会被彻底删除。

但是如果一个子进程已经终止,但父进程却不知道它的状态,这个进程将会成为 僵尸进程

服务与进程

简单的说服务(daemon)就是常驻内存的进程,通常服务会在开机时通过init.d中的一段脚本被启动。

进程通信

进程通信的几种基本方式:管道,信号量,消息队列,共享内存,快速用户控件互斥。

程序,进程和线程

现在我们再次详细的讨论这三个概念

程序(program)

程序是指编译过的、可执行的二进制代码,保存在储存介质上,不运行

进程(process)

进程是指正在运行的程序。

进程包括了很多资源,拥有自己独立的内存空间。

线程

线程是进程内的活动单元。

包括自己的虚拟储存器,如栈、进程状态如寄存器,以及指令指针。

  • 在单线程的进程中,线程即进程。而在多线程的进程中,多个线程将会共享同一个内存地址空间

  • 参考阅读

PID

可以参考之前的基础概念部分。

在C语言中,PID是由数据类型pid_t来表示的。

运行一个进程

创建一个进程,在unix系统中被分为了两个流程。

  1. 把程序载入内存并执行程序映像的操作:exec
  2. 创建一个新进程:fork

exec

最简单的exec系统调用函数:execl()

  • 函数原型:
int execl(const char * path,const chr * arg,...)

execl()调用将会把path所指的路径的映像载入内存,替换当前进程的映像。

参数arg是以第一个参数,参数内容是可变的,但最后必须以NULL结尾。

  • 举例:
int ret;

ret = execl("/bin/vi","vi",NULL);

if (ret == -1) {
    perror("execl");
}

上面的代码将会通过/bin/vi替换当前运行的程序

注意这里的第一个参数vi,是unix系统的默认惯例,当创建、执行进程时,shell会把路径中的最后部分放入新进程的第一个参数,这样可以使得进程解析出二进制映像文件的名字。

int ret;

ret = execl("/bin/vi","vi","/home/mark/a.txt",NULL);

if (ret == -1) {
    perror("execl");
}

上面的代码是一个非常有代表性的操作,这相当于你在终端执行以下命令:

vi /home/mark/a.txt
  • 返回值:

正常情况下其实execl()不会返回,调用成功后会跳转到新的程序入口点。

成功的execl()调用,将改变地址空间和进程映像,还改变了很多进程的其他属性。

不过进程的PID,PPID,优先级等参数将会被保留下来,甚至会保留下所打开的文件描述符(这就意味着它可以访问所有这些原本进程打开的文件)。

失败后将会返回-1,并更新errno。

其他exec系函数

略,使用时查找

fork

通过fork()系统调用,可以创建一个和当前进程映像一模一样的子进程。

  • 函数原型
pid_t fork(void)

调用成功后,会创建一个新的进程(子进程),这两个进程都会继续运行。

  • 返回值

如果调用成功,
父进程中,fork()会返回子进程的pid,在子进程中返回0;
如果失败,返回-1,并更新errno,不会创建子进程。

  • 举例

我们看下面这段代码

#include <unistd.h>
#include <stdio.h>
int main ()
{
    pid_t fpid; //fpid表示fork函数返回的值
    int count=0;

    printf("this is a process\n");

    fpid=fork();

    if (fpid < 0)
        printf("error in fork!");
    else if (fpid == 0) {
        printf("i am the child process, my process id is %d\n",getpid());
        printf("我是爹的儿子\n");
        count++;
    }
    else {
        printf("i am the parent process, my process id is %d\n",getpid());
        printf("我是孩子他爹\n");
        count++;
    }
    printf("统计结果是: %d\n",count);
    return 0;
}

这段代码的运行结果比较神奇,是这样的:

this is a process
i am the parent process, my process id is 21448
我是孩子他爹
统计结果是: 1
i am the child process, my process id is 21449
我是爹的儿子
统计结果是: 1

在执行了fork()之后,这个程序就拥有了两个进程,父进程和子进程分别往下继续执行代码,进入了不同的if分支。

如何理解pid在父子进程中不同?

其实就相当于链表,进程形成了链表,父进程的pid指向了子进程的pid,因为子进程没有子进程,所以pid为0。

写时复制

传统的fork机制是,调用fork时,内核会复制所有的内部数据结构,复制进程的页表项,然后把父进程的地址空间按页复制给子进程(非常耗时)。

现代的fork机制采用了一种惰性算法的优化策略。

为了避免复制时系统开销,就尽可能的减少“复制”操作,当多个进程需要读取他们自己那部分资源的副本时,并不复制多个副本出来,而是为每个进程设定一个文件指针,让它们读取同一个实际文件。

显然这样的方式会在写入时产生冲突(类似并发),于是当某个进程想要修改自己的那个副本时,再去复制该资源,(只有写入时才复制,所以叫写时复制)这样就减少了复制的频率。

联合实例

在程序中创建一个子进程,打开另一个应用。

pid_t pid;

pid = fork();

if (pid == -1)
    perror("fork");

//子进程
if (!pid) {
    const char * args[] = {"windlass",NULL};

    int ret;

    // 参数以数组方式传入
    ret = execv("/bin/windlass",args);

    if (ret == -1) {
        perror("execv");
        exit(EXIT_FAILURE);
    }
}

上面的程序创建了一个子进程,并且使子进程运行了/bin/windlas程序。

终止进程

exit()

  • 函数原型
void exit (int status)

该函数用于终止当前的进程,参数status只用于标识进程的退出状态,这个值将会被传送给当前进程的父进程用于判断。

还有一些其他的终止调用函数,在此不赘述。

等待子进程终止

如何通知父进程子进程终止?可以通过信号机制来实现这一点。但是在很多情况下,父进程需要知道有关子进程的更详细的信息(比如返回值),这时候简单的信号通知就显得无能为力了。

如果终止时,子进程已经完全被销毁,父进程就无法获取关于子进程的任何信息。

于是unix最初做了这样的设计,如果一个子进程在父进程之前结束,内核就把这个子进程设定成一种特殊的运行状态,这种状态下的进程被称为僵尸进程,它只保留最小的概要信息,等待父进程获取到了这些信息之后,才会被销毁。

wait()

  • 函数原型
pid_t wait(int * status);

这个函数可以用于获取已经终止的子进程的信息。

调用成功时,会返回已终止的子进程的pid,出错时返回-1。如果没有子进程终止会导致调用的阻塞直到有一个子进程终止。

waitpid()

  • 函数原型
pid_t waitpid(pid_t pid,int * status,int options);

waitpid()是一个更为强大的系统调用,支持更细粒度的管控。

一些其他可能会遇到的等待函数

  • wait3()

  • wait4()

简单的说,wait3等待任意一个子进程的终止,wait4等待一个指定子进程的终止。

创建并等待新进程

很多时候我们会遇到下面这种情景:

你创建了一个新进程,你想等待它调用完之后再继续运行你自己的进程,也就是说,创建一个新进程并立即开始等待它的终止。

一个合适的选择是system():

int system(const char * command);

system()函数将会调用command提供的命令,一般用于运行简单的工具和shell脚本。

成功时,返回的是执行command命令所得到的返回状态。

你可以使用fork(),exec(),waitpid()来实现一个system()。

下面给出一个简单的实现:

int my_system(const char * cmd)
{
    int status;
    pid_t pid;

    pid = fork();

    if (pid == -1) {
        return -1;
    }

    else if (pid == 0) {
        const char * argv[4];

        argv[0] = "sh";
        argv[1] = "-c";
        argv[2] = cmd;
        argv[3] = NULL;

        execv("bin/sh",argv);
        // 这传参调用好像有类型转换问题

        exit(-1);

    }//子进程

    //父进程
    if (waitpid(pid,&status,0) == -1)
        return -1;
    else if (WIFEXITED(status))
        return WEXITSTATUS(status);

    return -1;
}

幽灵进程

上面我们谈论到僵尸进程,但是如果父进程没有等待子进程的操作,那么它所有的子进程都将成为幽灵进程,幽灵进程将会一直存在(因为等不到父进程调用,就一直不终止),导致系统运行速度的拖慢。

正常情况下我们不该让这种情况发生,然而如果父进程在子进程结束之前就结束了,或者父进程还没有机会等待其僵尸进程的子进程,就先结束了,这样就不可避免的产生了幽灵进程。

linux内核有一个机制来避免这样的情况发生。

无论何时,只要有进程结束,内核就会遍历它的所有子进程,并且把他们的父进程重新设置为init,而init会周期性的等待所有的子进程,以确保没有长时间存在的幽灵进程。

进程与权限

略,待补充

会话和进程组

进程组

每个进程都属于某个进程组,进程组就是由一个或者多个为了实现作业控制而相互关联的进程组成的。

一个进程组的id是进程组首进程的pid(如果一个进程组只有一个进程,那进程组和进程其实没啥区别)。

进程组的意义在于,信号可以发送给进程组中的所有进程。这样可以实现对多个进程的同时操作。

会话

会话是一个或者多个进程组的集合。

一般来说,会话(session)和shell没有什么本质上的区别。

我们通常使用用户登录一个终端进行一系列操作这样的例子来描述一次会话。

  • 举例
$cat ship-inventory.txt | grep booty|sort

上面就是在某次会话中的一个shell命令,它会产生一个由3个进程组成的进程组。

守护进程(服务)

守护进程(daemon)运行在后台,不与任何控制终端相关联。通常在系统启动时通过init脚本被调用而开始运行。

在linux系统中,守护进程和服务没有什么区别。

对于一个守护进程,有两个基本的要求:其一:必须作为init进程的子进程运行,其二:不与任何控制终端交互。

产生一个守护进程的流程

  1. 调用fork()来创建一个子进程(它即将成为守护进程)
  2. 在该进程的父进程中调用exit(),这保证了父进程的父进程在其子进程结束时会退出,保证了守护进程的父进程不再继续运行,而且守护进程不是首进程。(它继承了父进程的进程组id,而且一定不是leader)
  3. 调用setsid(),给守护进程创建一个新的进程组和新的会话,并作为两者的首进程。这可以保证不存在和守护进程相关联的控制终端。
  4. 调用chdir(),将当前工作目录改为根目录。这是为了避免守护进程运行在原来fork的父进程打开的随机目录下,便于管理。
  5. 关闭所有的文件描述符。
  6. 打开文件描述符0,1,2(stdin,stdout,err),并把它们重定向到/dev/null

daemon()

用于实现上面的操作来产生一个守护进程

  • 函数原型
int daemon(int nochdir,int noclose);

如果参数nochdir是非0值,就不会将工作目录定向到根目录。
如果参数noclose是非0值,就不会关闭所有打开的文件描述符。

成功时返回0,失败返回-1。

注意调用这个函数生成的函数是父进程的副本(fork),所以最终生成的守护进程的样子就是父进程的样子,一般来说,就是在父进程中写好要运行在后台的功能代码,然后调用daemon()来把这些功能包装成一个守护进程。

这样子看上去好像是把当前执行的进程包装成了一个守护进程,但其实包装的是它派生出的一个副本。

线程

基础概念

线程是进程内的执行单元(比进程更低一层的概念),具体包括 虚拟处理器,堆栈,程序状态等。

可以认为 线程是操作系统调度的最小执行单元。

现代操作系统对用户空间做两个基础抽象:虚拟内存和虚拟处理器。这使得进程内部“感觉”自己独占机器资源。

虚拟内存

系统会为每个进程分配独立的内存空间,这会让进程以为自己独享全部的RAM。

但是同一个进程内的所有线程共享该进程的内存空间。

虚拟处理器

这是一个针对线程的概念,它让每个线程都“感觉”自己独享CPU。实际上对于进程也是一样的。

多线程

多线程的好处

  • 编程抽象

    模块化的设计模式

  • 并发

    在多核处理器上可以实现真正的并发,提高系统吞吐量

  • 提高响应能力

    防止串行运算僵死

  • 防止i/o阻塞

    避免单线程下,i/o操作导致整个进程阻塞的情况。此外也可以通过异步i/o和非阻塞i/o解决。

  • 减少上下文切换

    多线程的切换消耗的性能远比进程间的上下文切换小的多

  • 内存共享

    因为同一进程内的线程可以共享内存,在某些场景下可以利用这些特性,用多线程取代多进程。

多线程的代价

调试难度极大。

在同一个内存空间内并发性的读写操作会引发多种问题(如脏数据),对多进程情景下的资源同步变得困难,而且多个独立运行的线程其时间和顺序具有不可预测性,会导致各种各样奇怪的问题。

这一点可以参考并发带来的问题。

线程模型

线程的概念同时存在于内核和用户空间中。

内核级线程模型

每个内核线程直接转换成用户空间的线程。即内核线程:用户空间线程=1:1

用户级线程模型

这种模型下,一个保护了n个线程的用户进程只会映射到一个内核进程。即n:1。

可以减少上下文切换的成本,但在linux下没什么意义,因为linux下进程间的上下文切换本身就没什么消耗,所以很少使用。

混合式线程模型

上述两种模型的混合,即n:m型。

很难实现。

*协同程序

‌提供了比线程更轻量级的执行单位。

线程模式

每个连接对应一个线程

也就是阻塞式的I/O,实际就是单线程模式

线程以串行的方式运行,一个线程遇到I/O时线程必须被挂起等待直到操作完成后,才能再继续执行。

事件驱动的线程模式

单线程的操作模型中,大部分的系统负荷在于等待(尤其是I/O操作),因此在事件驱动的模式下,把这些等待操作从线程的执行过程中剥离掉,通过发送异步I/O请求或者是I/O多路复用,引入事件循环和回调来处理线程和I/O之间的关系。

有关I/O的几种模式,参考这里

简要概括一下,分为四种:

  • 阻塞IO:串行处理,单线程,同步等待
  • 非阻塞IO:线程发起IO请求后将立即得到结果而不是等待,如果IO没有处理完将返回ERROR,需要线程自己主动去向Kernel不断请求来判断IO是否完成
  • 异步IO:线程发起IO请求后,立即得到结果,Kernel执行完IO后会主动发送SIGNAL去通知线程
  • 事件驱动IO:属于非阻塞IO的一个升级,主要用于连接较多的情况,让Kernel去监视多个socket(每个socket都是非阻塞式的IO),哪个socket有结果了就继续执行哪个socket。

并发,并行,竞争!

并发和并行

并发,是指同一时间周期内需要运行(处理)多个线程。

并行,是指同一时刻有多个线程在运行。

本质上,并发是一种编程概念,而并行是一种硬件属性,并发可以通过并行的方式实现,也可以不通过并行的方式实现(单cpu)。

竞争

并发编程带来的最大挑战就是竞争,这主要是因为多个线程同时执行时,执行结果的顺序存在不可预料性

  • 一个最简单的示范,可以参考java并发编程中的基本例子。

    请看下面这行代码:

      x++;

    假设x的初始值为5,我们使用两个线程同时执行这行代码,会出现很多不一样的结果,即运行完成后,x的值可能为6,也可能为7。(这个是并发最基本的示范,自己理解一下很容易明白。)

    原因简要描述为下:

    一个线程执行x++的过程大概分为3步:

    1. 把x加载到寄存器
    2. 把寄存器的值+1
    3. 把寄存器的值写回到x中

      当两个线程出现竞争的时候,就是这3步执行的过程在时间上出现了不可预料性,假设线程1,2将x加载到寄存器的时候x都是5,但当线程1写回x时,x成为6,线程2写回x时,x还是6,这就与初衷相悖。

      如果有更多的线程结果将变得更加难以预料。

解决竞争的手段:同步

简要的说,就是在会发生竞争的资源上,取消并发,而是采用同步的方式访问和操作。

最常见的,处理并发的机制,就是锁机制了,当然系统层面的锁比DBMS等其他一些复杂系统的锁要简单一些(不存在共享锁,排他锁等一些较为复杂的概念)。

但是锁会带来两个问题:死锁饿死

解决这两个问题需要一些机制以及设计理念。具体有关锁的部分可以参考DBMS的并发笔记。

关于锁,有一点要记住。

锁住的是资源,而不是代码

编写代码时应该切记这个原则。

系统线程实现:PThreads

原始的linux系统调用中,没有像C++11或者是Java那样完整的线程库。

整体看来pthread的api比较冗余和复杂,但是基本操作也主要是 创建、退出等。

需要留意的一点是linux机制下,线程存在一个被称为joinable的状态。下面简要了解一下:

Join和Detach

这块的概念,非常类似于之前父子进程那部分,等待子进程退出的内容(一系列的wait函数)。

linux机制下,线程存在两种不同的状态:joinableunjoinable

如果一个线程被标记为joinable时,即便它的线程函数执行完了,或者使用了pthread_exit()结束了该线程,它所占用的堆栈资源和进程描述符都不会被释放(类似僵尸进程),这种情况应该由线程的创建者调用pthread_join()来等待线程的结束并回收其资源(类似wait系函数)。默认情况下创建的线程都是这种状态。

如果一个线程被标记成unjoinable,称它被分离(detach)了,这时候如果该线程结束,所有它的资源都会被自动回收。省去了给它擦屁股的麻烦。

因为创建的线程默认都是joinable的,所以要么在父线程调用pthread_detach(thread_id)将其分离,要么在线程内部,调用pthread_detach(pthread_self())来把自己标记成分离的。