Linux的进程间通信-信号量

 

Linux的进程间通信-信号量

版权声明:

本文章内容在非商业使用前提下可无需授权任意转载、发布。

转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:orroz

微信公众号:Linux系统技术

前言

信号量又叫信号灯,也有人把它叫做信号集,本文遵循《UNIX环境高级编程》的叫法,仍称其为信号量。它的英文是semaphores,本意是“旗语”“信号”的意思。由于其叫法中包含“信号”这个关键字,所以容易跟另一个信号signal搞混。在这里首先强调一下,Linux系统中的semaphore信号量和signal信号是完全不同的两个概念。我们将在其它文章中详细讲解信号signal。本文可以帮你学会:

  1. 什么是XSI信号量?
  2. 什么是PV操作及其应用。
  3. 什么是POSIX信号量?
  4. 信号量的操作方法及其实现。

我们已经知道文件锁对于多进程共享文件的必要性了,对一个文件加锁,可以防止多进程访问文件时的“竞争条件”。信号量提供了类似能力,可以处理不同状态下多进程甚至多线程对共享资源的竞争。它所起到的作用就像十字路口的信号灯或航船时的旗语,用来协调多个执行过程对临界区的访问。但是从本质上讲,信号量实际上是实现了一套可以实现类似锁功能的原语,我们不仅可以用它实现锁,还可以实现其它行为,比如经典的PV操作。

Linux环境下主要实现的信号量有两种。根据标准的不同,它们跟共享内存类似,一套XSI的信号量,一套POSIX的信号量。下面我们分别使用它们实现一套类似文件锁的方法,来简单看看它们的使用。

请扫描二维码,感谢您的捐助!

收钱

XSI信号量

XSI信号量就是内核实现的一个计数器,可以对计数器做甲减操作,并且操作时遵守一些基本操作原则,即:对计数器做加操作立即返回,做减操作要检查计数器当前值是否够减?(减被减数之后是否小于0)如果够,则减操作不会被阻塞;如果不够,则阻塞等待到够减为止。在此先给出其相关操作方法的原型:

#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);

可以使用semget创建或者打开一个已经创建的信号量数组。根据XSI共享内存中的讲解,我们应该已经知道第一个参数key用来标识系统内的信号量。这里除了可以使用ftok产生以外,还可以使用IPC_PRIVATE创建一个没有key的信号量。如果指定的key已经存在,则意味着打开这个信号量,这时nsems参数指定为0,semflg参数也指定为0。nsems参数表示在创建信号量数组的时候,这个数组中的信号量个数是几个。我们可以通过多个信号量的数组实现更复杂的信号量功能。最后一个semflg参数用来指定标志位,主要有:IPC_CREAT,IPC_EXCL和权限mode。

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semop(int semid, struct sembuf *sops, size_t nsops);

int semtimedop(int semid, struct sembuf *sops, size_t nsops, const struct timespec *timeout);

使用semop调用来对信号量数组进行操作。nsops指定对数组中的几个元素进行操作,如数组中只有一个信号量就指定为1。操作的所有参数都定义在一个sembuf结构体里,其内容如下:

unsigned short sem_num;  /* semaphore number */
short          sem_op;   /* semaphore operation */
short          sem_flg;  /* operation flags */

sem_flg可以指定的参数包括IPC_NOWAIT和SEM_UNDO。当制定了SEM_UNDO,进程退出的时候会自动UNDO它对信号量的操作。对信号量的操作会作用在指定的第sem_num个信号量。一个信号量集合中的第1个信号量的编号从0开始。所以,对于只有一个信号量的信号集,这个sem_num应指定为0。sem_op用来指定对信号量的操作,可以有的操作有三种:

正值操作:对信号量计数器的值(semval)进行加操作。

0值操作:对计数器的值没有影响,而且要求对进程对信号量必须有读权限。实际上这个行为是一个“等待计数器为0”的操作:如果计数器的值为0,则操作可以立即返回。如果不是0并且sem_flg被设置为IPC_NOWAIT的情况下,0值操作也不会阻塞,而是会立即返回,并且errno被设置为EAGAIN。如果不是0,且没设置IPC_NOWAIT时,操作会阻塞,直到计数器值变成0为止,此时相关信号量的semncnt值会加1,这个值用来记录有多少个进程(线程)在此信号量上等待。除了计数器变为0会导致阻塞停止以外,还有其他情况也会导致停止等待:信号量被删除,semop操作会失败,并且errno被置为EIDRM。进程被信号(signal)打断,errno会被置为EINTR,切semzcnt会被正常做减处理。

负值操作:对计数器做减操作,且进程对信号量必须有写权限。如果当前计数器的值大于或等于指定负值的绝对值,则semop可以立即返回,并且计数器的值会被置为减操作的结果。如果sem_op的绝对值大于计数器的值semval,则说明目前不够减,测试如果sem_flg设置了IPC_NOWAIT,semop操作依然会立即返回并且errno被置为EAGAIN。如果没设置IPC_NOWAIT,则会阻塞,直到以下几种情况发生为止:

  1. semval的值大于或等于sem_op的绝对值,这时表示有足够的值做减法了。
  2. 信号量被删除,semop返回EIDRM。
  3. 进程(线程)被信号打断,semop返回EINTR。

这些行为基本与0值操作类似。semtimedop提供了一个带超时机制的结构,以便实现等待超时。观察semop的行为我们会发现,有必要在一个信号量创建之后对其默认的计数器semval进行赋值。所以,我们需要在semop之前,使用semctl进行赋值操作。

int semctl(int semid, int semnum, int cmd, ...);

这个调用是一个可变参实现,具体参数要根据cmd的不同而变化。在一般的使用中,我们主要要学会使用它改变semval的值和查看、修改sem的属性。相关的cmd为:SETVAL、IPC_RMID、IPC_STAT。

一个简单的修改semval的例子:

semctl(semid, 0, SETVAL, 1);

这个调用可以将指定的sem的semval值设置为1。更具体的参数解释大家可以参考man 2 semctl。

以上就是信号量定义的原语意义。如果用它实现类似互斥锁的操作,那么我们就可以初始化一个默认计数器值为1的的信号量,当有人进行加锁操作的时候对其减1,解锁操作对其加1。于是对于一个已经被减1的信号量计数器来说,再有人加锁会导致阻塞等待,直到加锁的人解锁后才能再被别人加锁。

我们结合例子来看一下它们的使用,我们用sem实现一套互斥锁,这套锁除了可以锁文件,也可以用来给共享内存加锁,我们可以用它来保护上面共享内存使用的时的临界区。我们使用xsi共享内存的代码案例为例子:

[zorro@zorro-pc sem]$ cat racing_xsi_shm.c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <wait.h>
#include <sys/mman.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/sem.h>

#define COUNT 100
#define PATHNAME "/etc/passwd"

static int lockid;

int mylock_init(void)
{
    int semid;

    semid = semget(IPC_PRIVATE, 1, IPC_CREAT|0600);
    if (semid < 0) {
        perror("semget()");
        return -1;
    }
    if (semctl(semid, 0, SETVAL, 1) < 0) {
        perror("semctl()");
        return -1;
    }
    return semid;
}

void mylock_destroy(int lockid)
{
    semctl(lockid, 0, IPC_RMID);
}

int mylock(int lockid)
{
    struct sembuf sbuf;

    sbuf.sem_num = 0;
    sbuf.sem_op = -1;
    sbuf.sem_flg = 0;

    while (semop(lockid, &sbuf, 1) < 0) {
        if (errno == EINTR) {

            continue;
        }
        perror("semop()");
        return -1;
    }

    return 0;
}

int myunlock(int lockid)
{
    struct sembuf sbuf;

    sbuf.sem_num = 0;
    sbuf.sem_op = 1;
    sbuf.sem_flg = 0;

    if (semop(lockid, &sbuf, 1) < 0) {
        perror("semop()");
        return -1;
    }

    return 0;
}

int do_child(int proj_id)
{
    int interval;
    int *shm_p, shm_id;
    key_t shm_key;

    if ((shm_key = ftok(PATHNAME, proj_id)) == -1) {
        perror("ftok()");
        exit(1);
    }

    shm_id = shmget(shm_key, sizeof(int), 0);
    if (shm_id < 0) {
        perror("shmget()");
        exit(1);
    }

    shm_p = (int *)shmat(shm_id, NULL, 0);
    if ((void *)shm_p == (void *)-1) {
        perror("shmat()");
        exit(1);
    }

    /* critical section */
    if (mylock(lockid) == -1) {
        exit(1);
    }
    interval = *shm_p;
    interval++;
    usleep(1);
    *shm_p = interval;
    if (myunlock(lockid) == -1) {
        exit(1);
    }
    /* critical section */

    if (shmdt(shm_p) < 0) {
        perror("shmdt()");
        exit(1);
    }

    exit(0);
}

int main()
{
    pid_t pid;
    int count;
    int *shm_p;
    int shm_id, proj_id;
    key_t shm_key;

    lockid = mylock_init();
    if (lockid == -1) {
        exit(1);
    }

    proj_id = 1234;
    if ((shm_key = ftok(PATHNAME, proj_id)) == -1) {
        perror("ftok()");
        exit(1);
    }

    shm_id = shmget(shm_key, sizeof(int), IPC_CREAT|IPC_EXCL|0600);
    if (shm_id < 0) {
        perror("shmget()");
        exit(1);
    }

    shm_p = (int *)shmat(shm_id, NULL, 0);
    if ((void *)shm_p == (void *)-1) {
        perror("shmat()");
        exit(1);
    }

    *shm_p = 0;

    for (count=0;count<COUNT;count++) {
        pid = fork();
        if (pid < 0) {
            perror("fork()");
            exit(1);
        }

        if (pid == 0) {
            do_child(proj_id);
        }
    }

    for (count=0;count<COUNT;count++) {
        wait(NULL);
    }

    printf("shm_p: %d\n", *shm_p);

    if (shmdt(shm_p) < 0) {
        perror("shmdt()");
        exit(1);
    }

    if (shmctl(shm_id, IPC_RMID, NULL) < 0) {
        perror("shmctl()");
        exit(1);
    }

    mylock_destroy(lockid);

    exit(0);
}

此时可以得到正确的执行结果:

[zorro@zorro-pc sem]$ ./racing_xsi_shm 
shm_p: 100

大家可以自己思考一下,如何使用信号量来完善这个所有的锁的操作行为,并补充以下方法:

  1. 实现trylock。
  2. 实现共享锁。
  3. 在共享锁的情况下,实现查看当前有多少人以共享方式加了同一把锁。

系统中对于XSI信号量的限制都放在一个文件中,路径为:/proc/sys/kernel/sem。文件中包涵4个限制值,它们分别的含义是:

SEMMSL:一个信号量集(semaphore set)中,最多可以有多少个信号量。这个限制实际上就是semget调用的第二个参数的个数上限。

SEMMNS:系统中在所有信号量集中最多可以有多少个信号量。

SEMOPM:可以使用semop系统调用指定的操作数限制。这个实际上是semop调用中,第二个参数的结构体中的sem_op的数字上限。

SEMMNI:系统中信号量的id标示数限制。就是信号量集的个数上限。

PV操作原语

PV操作是操作系统原理中的重点内容之一,而根据上述的互斥锁功能的描述来看,实际上我们的互斥锁就是一个典型的PV操作。加锁行为就是P操作,解锁就是V操作。PV操作是计算机操作系统需要提供的基本功能之一。最开始它用来在只有1个CPU的计算机系统上实现多任务操作系统的功能原语。试想,多任务操作系统意味着系统中同时可以执行多个进程,但是CPU只有一个,那就意味着某一个时刻实际上只能有一个进程占用CPU,而其它进程此时都要等着。基于这个考虑,1962年狄克斯特拉在THE系统中提出了PV操作原语的设计,来实现多进程占用CPU资源的控制原语。在理解了互斥锁之后,我们能够意识到,临界区代码段实际上跟多进程使用一个CPU的环境类似,它们都是对竞争条件下的有限资源。对待这样的资源,就有必要使用PV操作原语进行控制。


请扫描二维码,感谢您的捐助!

收钱


根据这个思路,我们再扩展来看一个应用。我们都知道现在的计算机基本都是多核甚至多CPU的场景,所以很多计算任务如果可以并发执行,那么无疑可以增加计算能力。假设我们使用多进程的方式进行并发运算,那么并发多少个进程合适呢?虽然说这个问题会根据不同的应用场景发生变化,但是如果假定是一个极度消耗CPU的运算的话,那么无疑有几个CPU就应该并发几个进程。此时并发个数如果过多,则会增加调度开销导致整体吞度量下降,而过少则无法利用多个CPU核心。PV操作正好是一种可以实现类似方法的一种编程原语。我们假定一个应用模型,这个应用要找到从10010001到10020000数字范围内的质数。如果采用并发的方式,我们可以考虑给每一个要判断的数字都用一个进程去计算,但是这样无疑会使进程个数远远大于一般计算机的CPU个数。于是我们就可以在产生进程的时候,使用PV操作原语来控制同时进行运算的进程个数。这套PV原语的实现其实跟上面的互斥锁区别不大,对于互斥锁,计数器的初值为1,而对于这个PV操作,无非就是计数器的初值设置为当前计算机的核心个数,具体代码实现如下:

[zorro@zorro-pc sem]$ cat sem_pv_prime.c
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>

#define START 10010001
#define END 10020000
#define NPROC 4

static int pvid;

int mysem_init(int n)
{
    int semid;

    semid = semget(IPC_PRIVATE, 1, IPC_CREAT|0600);
    if (semid < 0) {
        perror("semget()");
        return -1;
    }
    if (semctl(semid, 0, SETVAL, n) < 0) {
        perror("semctl()");
        return -1;
    }
    return semid;
}

void mysem_destroy(int pvid)
{
    semctl(pvid, 0, IPC_RMID);
}

int P(int pvid)
{
    struct sembuf sbuf;

    sbuf.sem_num = 0;
    sbuf.sem_op = -1;
    sbuf.sem_flg = 0;

    while (semop(pvid, &sbuf, 1) < 0) {
        if (errno == EINTR) {
            continue;
        }
        perror("semop(p)");
        return -1;
    }

    return 0;
}

int V(int pvid)
{
    struct sembuf sbuf;

    sbuf.sem_num = 0;
    sbuf.sem_op = 1;
    sbuf.sem_flg = 0;

    if (semop(pvid, &sbuf, 1) < 0) {
        perror("semop(v)");
        return -1;
    }
    return 0;
}

int prime_proc(int n)
{
    int i, j, flag;

    flag = 1;
    for (i=2;i<n/2;++i) {
        if (n%i == 0) {
            flag = 0;
            break;
        }
    }
    if (flag == 1) {
        printf("%d is a prime\n", n);
    }
    /* 子进程判断完当前数字退出之前进行V操作 */
    V(pvid);
    exit(0);
}

void sig_child(int sig_num)
{
    while (waitpid(-1, NULL, WNOHANG) > 0);
}

int main(void)
{
    pid_t pid;
    int i;

    /* 当子进程退出的时候使用信号处理进行回收,以防止产生很多僵尸进程 */

    if (signal(SIGCHLD, sig_child) == SIG_ERR) {
        perror("signal()");
        exit(1);
    }

    pvid = mysem_init(NPROC);

    /* 每个需要运算的数字都打开一个子进程进行判断 */
    for (i=START;i<END;i+=2) {
        /* 创建子进程的时候进行P操作。 */
        P(pvid);
        pid = fork();
        if (pid < 0) {
            /* 如果创建失败则应该V操作 */
            V(pvid);
            perror("fork()");
            exit(1);
        }
        if (pid == 0) {
            /* 创建子进程进行这个数字的判断 */
            prime_proc(i);
        }
    }
    /* 在此等待所有数都运算完,以防止运算到最后父进程先mysem_destroy,导致最后四个子进程进行V操作时报错 */
    while (1) {sleep(1);};
    mysem_destroy(pvid);
    exit(0);
}

整个进程组的执行逻辑可以描述为,父进程需要运算判断10010001到10020000数字范围内所有出现的质数,采用每算一个数打开一个子进程的方式。为控制同时进行运算的子进程个数不超过CPU个数,所以申请了一个值为CPU个数的信号量计数器,每创建一个子进程,就对计数器做P操作,子进程运算完推出对计数器做V操作。由于P操作在计数器是0的情况下会阻塞,直到有其他子进程退出时使用V操作使计数器加1,所以整个进程组不会产生大于CPU个数的子进程进行任务的运算。

这段代码使用了信号处理的方式回收子进程,以防产生过多的僵尸进程,这种编程方法比较多用在daemon中。使用这个方法引出的问题在于,如果父进程不在退出前等所有子进程回收完毕,那么父进程将在最后几个子进程执行完之前就将信号量删除了,导致最后几个子进程进行V操作的时候会报错。当然,我们可以采用更优雅的方式进程处理,但是那并不是本文要突出讲解的内容,大家可以自行对相关方法进行完善。一般的daemon进程正常情况下父进程不会主动退出,所以不会有类似问题。

POSIX信号量

POSIX提供了一套新的信号量原语,其原型定义如下:

#include <fcntl.h> 
#include <sys/stat.h>
#include <semaphore.h>

sem_t *sem_open(const char *name, int oflag);
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);

使用sem_open来创建或访问一个已经创建的POSIX信号量。创建时,可以使用value参数对其直接赋值。

int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);

sem_wait会对指定信号量进行减操作,如果信号量原值大于0,则减操作立即返回。如果当前值为0,则sem_wait会阻塞,直到能减为止。

int sem_post(sem_t *sem);

sem_post用来对信号量做加操作。这会导致某个已经使用sem_wait等在这个信号量上的进程返回。

int sem_getvalue(sem_t *sem, int *sval);

sem_getvalue用来返回当前信号量的值到sval指向的内存地址中。如果当前有进程使用sem_wait等待此信号量,POSIX可以允许有两种返回,一种是返回0,另一种是返回一个负值,这个负值的绝对值就是等待进程的个数。Linux默认的实现是返回0。

int sem_unlink(const char *name);

int sem_close(sem_t *sem);

使用sem_close可以在进程内部关闭一个信号量,sem_unlink可以在系统中删除信号量。

POSIX信号量实现的更清晰简洁,相比之下,XSI信号量更加复杂,但是却更佳灵活,应用场景更加广泛。在XSI信号量中,对计数器的加和减操作都是通过semop方法和一个sembuff的结构体来实现的,但是在POSIX中则给出了更清晰的定义:使用sem_post函数可以增加信号量计数器的值,使用sem_wait可以减少计数器的值。如果计数器的值当前是0,则sem_wait操作会阻塞到值大于0。

POSIX信号量也提供了两种方式的实现,命名信号量和匿名信号量。这有点类似XSI方式使用ftok文件路径创建和IPC_PRIVATE方式创建的区别。但是表现形式不太一样:

命名信号量:

命名信号量实际上就是有一个文件名的信号量。跟POSIX共享内存类似,信号量也会在/dev/shm目录下创建一个文件,如果有这个文件名就是一个命名信号量。其它进程可以通过这个文件名来通过sem_open方法使用这个信号量。除了访问一个命名信号量以外,sem_open方法还可以创建一个信号量。创建之后,就可以使用sem_wait、sem_post等方法进行操作了。这里要注意的是,一个命名信号量在用sem_close关闭之后,还要使用sem_unlink删除其文件名,才算彻底被删除。

匿名信号量:

一个匿名信号量仅仅就是一段内存区,并没有一个文件名与之对应。匿名信号量使用sem_init进行初始化,使用sem_destroy()销毁。操作方法跟命名信号量一样。匿名内存的初始化方法跟sem_open不一样,sem_init要求对一段已有内存进行初始化,而不是在/dev/shm下产生一个文件。这就要求:如果信号量是在一个进程中的多个线程中使用,那么它所在的内存区应该是这些线程应该都能访问到的全局变量或者malloc分配到的内存。如果是在多个进程间共享,那么这段内存应该本身是一段共享内存(使用mmap、shmget或shm_open申请的内存)。

POSIX共享内存所涉及到的其它方法应该也都比较简单,更详细的帮助参考相关的man手册即可,下面我们分别给出使用命名和匿名信号量的两个代码例子:

命名信号量使用:

[zorro@zorro-pc sem]$ cat racing_posix_shm.c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <wait.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <semaphore.h>

#define COUNT 100
#define SHMPATH "/shm"
#define SEMPATH "/sem"

static sem_t *sem;

sem_t *mylock_init(void)
{
    sem_t * ret;
    ret = sem_open(SEMPATH, O_CREAT|O_EXCL, 0600, 1);
    if (ret == SEM_FAILED) {
        perror("sem_open()");
        return NULL;
    }
    return ret;
}

void mylock_destroy(sem_t *sem)
{
    sem_close(sem);
    sem_unlink(SEMPATH);
}

int mylock(sem_t *sem)
{
    while (sem_wait(sem) < 0) {
        if (errno == EINTR) {
            continue;
        }
        perror("sem_wait()");
        return -1;
    }

    return 0;
}

int myunlock(sem_t *sem)
{
    if (sem_post(sem) < 0) {
        perror("semop()");
        return -1;
    }
}

int do_child(char * shmpath)
{
    int interval, shmfd, ret;
    int *shm_p;

    shmfd = shm_open(shmpath, O_RDWR, 0600);
    if (shmfd < 0) {
        perror("shm_open()");
        exit(1);
    }

    shm_p = (int *)mmap(NULL, sizeof(int), PROT_WRITE|PROT_READ, MAP_SHARED, shmfd, 0);
    if (MAP_FAILED == shm_p) {
        perror("mmap()");
        exit(1);
    }
    /* critical section */
    mylock(sem);
    interval = *shm_p;
    interval++;
    usleep(1);
    *shm_p = interval;
    myunlock(sem);
    /* critical section */
    munmap(shm_p, sizeof(int));
    close(shmfd);

    exit(0);
}

int main()
{
    pid_t pid;
    int count, shmfd, ret;
    int *shm_p;

    sem = mylock_init();
    if (sem == NULL) {
        fprintf(stderr, "mylock_init(): error!\n");
        exit(1);
    }

    shmfd = shm_open(SHMPATH, O_RDWR|O_CREAT|O_TRUNC, 0600);
    if (shmfd < 0) {
        perror("shm_open()");
        exit(1);
    }

    ret = ftruncate(shmfd, sizeof(int));
    if (ret < 0) {
        perror("ftruncate()");
        exit(1);
    }

    shm_p = (int *)mmap(NULL, sizeof(int), PROT_WRITE|PROT_READ, MAP_SHARED, shmfd, 0);
    if (MAP_FAILED == shm_p) {
        perror("mmap()");
        exit(1);
    }

    *shm_p = 0;

    for (count=0;count<COUNT;count++) {
        pid = fork();
        if (pid < 0) {
            perror("fork()");
            exit(1);
        }

        if (pid == 0) {
            do_child(SHMPATH);
        }
    }

    for (count=0;count<COUNT;count++) {
        wait(NULL);
    }

    printf("shm_p: %d\n", *shm_p);
    munmap(shm_p, sizeof(int));
    close(shmfd);
    shm_unlink(SHMPATH);
    sleep(3000);
    mylock_destroy(sem);
    exit(0);
}

匿名信号量使用:

[zorro@zorro-pc sem]$ cat racing_posix_shm_unname.c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <wait.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <semaphore.h>

#define COUNT 100
#define SHMPATH "/shm"

static sem_t *sem;

void mylock_init(void)
{
    sem_init(sem, 1, 1);
}

void mylock_destroy(sem_t *sem)
{
    sem_destroy(sem);
}

int mylock(sem_t *sem)
{
    while (sem_wait(sem) < 0) {
        if (errno == EINTR) {
            continue;
        }
        perror("sem_wait()");
        return -1;
    }

    return 0;
}

int myunlock(sem_t *sem)
{
    if (sem_post(sem) < 0) {
        perror("semop()");
        return -1;
    }
}

int do_child(char * shmpath)
{
    int interval, shmfd, ret;
    int *shm_p;

    shmfd = shm_open(shmpath, O_RDWR, 0600);
    if (shmfd < 0) {
        perror("shm_open()");
        exit(1);
    }

    shm_p = (int *)mmap(NULL, sizeof(int), PROT_WRITE|PROT_READ, MAP_SHARED, shmfd, 0);
    if (MAP_FAILED == shm_p) {
        perror("mmap()");
        exit(1);
    }
    /* critical section */
    mylock(sem);
    interval = *shm_p;
    interval++;
    usleep(1);
    *shm_p = interval;
    myunlock(sem);
    /* critical section */
    munmap(shm_p, sizeof(int));
    close(shmfd);

    exit(0);
}

int main()
{
    pid_t pid;
    int count, shmfd, ret;
    int *shm_p;

    sem = (sem_t *)mmap(NULL, sizeof(sem_t), PROT_WRITE|PROT_READ, MAP_SHARED|MAP_ANONYMOUS, -1, 0);
    if ((void *)sem == MAP_FAILED) {
        perror("mmap()");
        exit(1);
    }

    mylock_init();

    shmfd = shm_open(SHMPATH, O_RDWR|O_CREAT|O_TRUNC, 0600);
    if (shmfd < 0) {
        perror("shm_open()");
        exit(1);
    }

    ret = ftruncate(shmfd, sizeof(int));
    if (ret < 0) {
        perror("ftruncate()");
        exit(1);
    }

    shm_p = (int *)mmap(NULL, sizeof(int), PROT_WRITE|PROT_READ, MAP_SHARED, shmfd, 0);
    if (MAP_FAILED == shm_p) {
        perror("mmap()");
        exit(1);
    }

    *shm_p = 0;

    for (count=0;count<COUNT;count++) {
        pid = fork();
        if (pid < 0) {
            perror("fork()");
            exit(1);
        }

        if (pid == 0) {
            do_child(SHMPATH);
        }
    }

    for (count=0;count<COUNT;count++) {
        wait(NULL);
    }

    printf("shm_p: %d\n", *shm_p);
    munmap(shm_p, sizeof(int));
    close(shmfd);
    shm_unlink(SHMPATH);
    sleep(3000);
    mylock_destroy(sem);
    exit(0);
}

以上程序没有仔细考究,只是简单列出了用法。另外要注意的是,这些程序在编译的时候需要加额外的编译参数-lrt和-lpthread。

最后

希望这些内容对大家进一步深入了解Linux的信号量。如果有相关问题,可以在我的微博、微信或者博客上联系我。

请扫描二维码,感谢您的捐助!

收钱


大家好,我是Zorro!

如果你喜欢本文,欢迎在微博上搜索“orroz”关注我,地址是:http://weibo.com/orroz

大家也可以在微信上搜索:Linux系统技术 关注我的公众号。

我的所有文章都会沉淀在我的个人博客上,地址是:http://liwei.life。

欢迎使用以上各种方式一起探讨学习,共同进步。

公众号二维码:

Zorro] icon


 

Linux进程间通信-共享内存


Linux进程间通信-共享内存

版权声明:

本文章内容在非商业使用前提下可无需授权任意转载、发布。

转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:orroz

微信公众号:Linux系统技术

前言

本文主要说明在Linux环境上如何使用共享内存。阅读本文可以帮你解决以下问题:

  1. 什么是共享内存和为什么要有共享内存?
  2. 如何使用mmap进行共享内存?
  3. 如何使用XSI共享内存?
  4. 如何使用POSIX共享内存?
  5. 如何使用hugepage共享内存以及共享内存的相关限制如何配置?
  6. 共享内存都是如何实现的?

使用文件或管道进行进程间通信会有很多局限性,比如效率问题以及数据处理使用文件描述符而不如内存地址访问方便,于是多个进程以共享内存的方式进行通信就成了很自然要实现的IPC方案。Linux系统在编程上为我们准备了多种手段的共享内存方案。包括:

  1. mmap内存共享映射。
  2. XSI共享内存。
  3. POSIX共享内存。

下面我们就来分别介绍一下这三种内存共享方式。

如果觉得本文有用,请刷二维码任意捐助:

收钱

mmap内存共享映射

mmap本来的是存储映射功能。它可以将一个文件映射到内存中,在程序里就可以直接使用内存地址对文件内容进行访问,这可以让程序对文件访问更方便。其相关调用API原型如下:

#include <sys/mman.h>

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

int munmap(void *addr, size_t length);

由于这个系统调用的特性可以用在很多场合,所以Linux系统用它实现了很多功能,并不仅局限于存储映射。在这主要介绍的就是用mmap进行多进程的内存共享功能。Linux产生子进程的系统调用是fork,根据fork的语义以及其实现,我们知道新产生的进程在内存地址空间上跟父进程是完全一致的。所以Linux的mmap实现了一种可以在父子进程之间共享内存地址的方式,其使用方法是:

  1. 父进程将flags参数设置MAP_SHARED方式通过mmap申请一段内存。内存可以映射某个具体文件,也可以不映射具体文件(fd置为-1,flag设置为MAP_ANONYMOUS)。
  2. 父进程调用fork产生子进程。之后在父子进程内都可以访问到mmap所返回的地址,就可以共享内存了。

我们写一个例子试一下,这次我们并发100个进程写共享内存来看看竞争条件racing的情况:

[zorro@zorrozou-pc0 sharemem]$ cat racing_mmap.c 
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <wait.h>
#include <sys/mman.h>

#define COUNT 100

int do_child(int *count)
{
    int interval;

    /* critical section */
    interval = *count;
    interval++;
    usleep(1);
    *count = interval;
    /* critical section */

    exit(0);
}

int main()
{
    pid_t pid;
    int count;
    int *shm_p;

    shm_p = (int *)mmap(NULL, sizeof(int), PROT_WRITE|PROT_READ, MAP_SHARED|MAP_ANONYMOUS, -1, 0);
    if (MAP_FAILED == shm_p) {
        perror("mmap()");
        exit(1);
    }

    *shm_p = 0;

    for (count=0;count<COUNT;count++) {
        pid = fork();
        if (pid < 0) {
            perror("fork()");
            exit(1);
        }

        if (pid == 0) {
            do_child(shm_p);
        }
    }

    for (count=0;count<COUNT;count++) {
        wait(NULL);
    }

    printf("shm_p: %d\n", *shm_p);
    munmap(shm_p, sizeof(int));
    exit(0);
}

这个例子中,我们在子进程中为了延长临界区(critical section)处理的时间,使用了一个中间变量进行数值交换,并且还使用了usleep加强了一下racing的效果,最后执行结果:

[zorro@zorrozou-pc0 sharemem]$ ./racing_mmap 
shm_p: 20
[zorro@zorrozou-pc0 sharemem]$ ./racing_mmap 
shm_p: 17
[zorro@zorrozou-pc0 sharemem]$ ./racing_mmap 
shm_p: 14
[zorro@zorrozou-pc0 sharemem]$ ./racing_mmap 
shm_p: 15

这段共享内存的使用是有竞争条件存在的,从文件锁的例子我们知道,进程间通信绝不仅仅是通信这么简单,还需要处理类似这样的临界区代码。在这里,我们也可以使用文件锁进行处理,但是共享内存使用文件锁未免显得太不协调了。除了不方便以及效率低下以外,文件锁还不能够进行更高级的进程控制。所以,我们在此需要引入更高级的进程同步控制原语来实现相关功能,这就是信号量(semaphore)的作用。我们会在后续章节中集中讲解信号量的使用,在此只需了解使用mmap共享内存的方法。

我们有必要了解一下mmap的内存占用情况,以便知道使用它的成本。我们申请一段比较大的共享内存进行使用,并察看内存占用情况。测试代码:

[zorro@zorrozou-pc0 sharemem]$ cat mmap_mem.c 
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <wait.h>
#include <sys/mman.h>

#define COUNT 100
#define MEMSIZE 1024*1024*1023*2

int main()
{
    pid_t pid;
    int count;
    void *shm_p;

    shm_p = mmap(NULL, MEMSIZE, PROT_WRITE|PROT_READ, MAP_SHARED|MAP_ANONYMOUS, -1, 0);
    if (MAP_FAILED == shm_p) {
        perror("mmap()");
        exit(1);
    }

    bzero(shm_p, MEMSIZE);

    sleep(3000);

    munmap(shm_p, MEMSIZE);
    exit(0);
}

我们申请一段大概2G的共享内存,并置0。然后在执行前后分别看内存用量,看什么部分有变化:

[zorro@zorrozou-pc0 sharemem]$ free -g
              total        used        free      shared  buff/cache   available
Mem:             15           2           2           0          10          11
Swap:            31           0          31
[zorro@zorrozou-pc0 sharemem]$ ./mmap_mem &
[1] 32036
[zorro@zorrozou-pc0 sharemem]$ free -g
              total        used        free      shared  buff/cache   available
Mem:             15           2           0           2          12           9
Swap:            31           0          31

可以看到,这部分内存的使用被记录到了shared和buff/cache中。当然这个结果在不同版本的Linux上可能是不一样的,比如在Centos 6的环境中mmap的共享内存只会记录到buff/cache中。除了占用空间的问题,还应该注意,mmap方式的共享内存只能在通过fork产生的父子进程间通信,因为除此之外的其它进程无法得到共享内存段的地址。

XSI共享内存

为了满足多个无关进程共享内存的需求,Linux提供了更具通用性的共享内存手段,XSI共享内存就是这样一种实现。XSI是X/Open组织对UNIX定义的一套接口标准(X/Open System Interface)。由于UNIX系统的历史悠久,在不同时间点的不同厂商和标准化组织定义过一些列标准,而目前比较通用的标准实际上是POSIX。我们还会经常遇到的标准还包括SUS(Single UNIX Specification)标准,它们大概的关系是,SUS是POSIX标准的超集,定义了部分额外附加的接口,这些接口扩展了基本的POSIX规范。相应的系统接口全集被称为XSI标准,除此之外XSI还定义了实现必须支持的POSIX的哪些可选部分才能认为是遵循XSI的。它们包括文件同步,存储映射文件,存储保护及线程接口。只有遵循XSI标准的实现才能称为UNIX操作系统。

XSI共享内存在Linux底层的实现实际上跟mmap没有什么本质不同,只是在使用方法上有所区别。其使用的相关方法为:

#include <sys/ipc.h>
#include <sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);

int shmctl(int shmid, int cmd, struct shmid_ds *buf);

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

void *shmat(int shmid, const void *shmaddr, int shmflg);

int shmdt(const void *shmaddr);

我们首先要来理解的是key这一个参数。想象一下我们现在需要解决的问题:“在一个操作系统内,如何让两个不相关(没有父子关系)的进程可以共享一个内存段?”系统中是否有现成的解决方案呢?当然有,就是文件。我们知道,文件的设计就可以让无关的进程可以进行数据交换。文件采用路径和文件名作为系统全局的一个标识符,但是每个进程打开这个文件之后,在进程内部都有一个“文件描述符”去指向文件。此时进程通过fork打开的子进程可以继承父进程的文件描述符,但是无关进程依然可以通过系统全局的文件名用open系统调用再次打开同一个文件,以便进行进程间通信。

实际上对于XSI的共享内存,其key的作用就类似文件的文件名,shmget返回的int类型的shmid就类似文件描述符,注意只是“类似”,而并非是同样的实现。这意味着,我们在进程中不能用select、poll、epoll这样的方法去控制一个XSI共享内存,因为它并不是“文件描述符”。对于一个XSI的共享内存,其key是系统全局唯一的,这就方便其他进程使用同样的key,打开同样一段共享内存,以便进行进程间通信。而使用fork产生的子进程,则可以直接通过shmid访问到相关共享内存段。这就是key的本质:系统中对XSI共享内存的全局唯一表示符。


如果觉得本文有用,请刷二维码任意捐助:

收钱


明白了这个本质之后,我们再来看看这个key应该如何产生。相关方法为:

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

key_t ftok(const char *pathname, int proj_id);

一个key是通过ftok函数,使用一个pathname和一个proj_jd产生的。就是说,在一个可能会使用共享内存的项目组中,大家可以约定一个文件名和一个项目的proj_id,来在同一个系统中确定一段共享内存的key。ftok并不会去创建文件,所以必须指定一个存在并且进程可以访问的pathname路径。这里还要指出的一点是,ftok实际上并不是根据文件的文件路径和文件名(pathname)产生key的,在实现上,它使用的是指定文件的inode编号和文件所在设备的设备编号。所以,不要以为你是用了不同的文件名就一定会得到不同的key,因为不同的文件名是可以指向相同inode编号的文件的(硬连接)。也不要认为你是用了相同的文件名就一定可以得到相同的key,在一个系统上,同一个文件名会被删除重建的几率是很大的,这种行为很有可能导致文件的inode变化。所以一个ftok的执行会隐含stat系统调用也就不难理解了。

最后大家还应该明白,key作为全局唯一标识不仅仅体现在XSI的共享内存中,XSI标准的其他进程间通信机制(信号量数组和消息队列)也使用这一命名方式。这部分内容在《UNIX环境高级编程》一书中已经有了很详尽的讲解,本文不在赘述。我们还是只来看一下它使用的例子,我们用XSI的共享内存来替换刚才的mmap:

[zorro@zorrozou-pc0 sharemem]$ cat racing_xsi_shm.c 
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <wait.h>
#include <sys/mman.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>

#define COUNT 100
#define PATHNAME "/etc/passwd"

int do_child(int proj_id)
{
    int interval;
    int *shm_p, shm_id;
    key_t shm_key;
    /* 使用ftok产生shmkey */
    if ((shm_key = ftok(PATHNAME, proj_id)) == -1) {
        perror("ftok()");
        exit(1);
    }
    /* 在子进程中使用shmget取到已经在父进程中创建好的共享内存id,注意shmget的第三个参数的使用。 */
    shm_id = shmget(shm_key, sizeof(int), 0);
    if (shm_id < 0) {
        perror("shmget()");
        exit(1);
    }

    /* 使用shmat将相关共享内存段映射到本进程的内存地址。 */

    shm_p = (int *)shmat(shm_id, NULL, 0);
    if ((void *)shm_p == (void *)-1) {
        perror("shmat()");
        exit(1);
    }

    /* critical section */
    interval = *shm_p;
    interval++;
    usleep(1);
    *shm_p = interval;
    /* critical section */

    /* 使用shmdt解除本进程内对共享内存的地址映射,本操作不会删除共享内存。 */
    if (shmdt(shm_p) < 0) {
        perror("shmdt()");
        exit(1);
    }

    exit(0);
}

int main()
{
    pid_t pid;
    int count;
    int *shm_p;
    int shm_id, proj_id;
    key_t shm_key;

    proj_id = 1234;

    /* 使用约定好的文件路径和proj_id产生shm_key。 */
    if ((shm_key = ftok(PATHNAME, proj_id)) == -1) {
        perror("ftok()");
        exit(1);
    }

    /* 使用shm_key创建一个共享内存,如果系统中已经存在此共享内存则报错退出,创建出来的共享内存权限为0600。 */
    shm_id = shmget(shm_key, sizeof(int), IPC_CREAT|IPC_EXCL|0600);
    if (shm_id < 0) {
        perror("shmget()");
        exit(1);
    }

    /* 将创建好的共享内存映射进父进程的地址以便访问。 */
    shm_p = (int *)shmat(shm_id, NULL, 0);
    if ((void *)shm_p == (void *)-1) {
        perror("shmat()");
        exit(1);
    }

    /* 共享内存赋值为0。 */
    *shm_p = 0;

    /*  打开100个子进程并发读写共享内存。 */
    for (count=0;count<COUNT;count++) {
        pid = fork();
        if (pid < 0) {
            perror("fork()");
            exit(1);
        }

        if (pid == 0) {
            do_child(proj_id);
        }
    }

    /* 等待所有子进程执行完毕。 */
    for (count=0;count<COUNT;count++) {
        wait(NULL);
    }

    /* 显示当前共享内存的值。 */
    printf("shm_p: %d\n", *shm_p);


    /* 解除共享内存地质映射。 */
    if (shmdt(shm_p) < 0) {
        perror("shmdt()");
        exit(1);
    }

    /* 删除共享内存。 */
    if (shmctl(shm_id, IPC_RMID, NULL) < 0) {
        perror("shmctl()");
        exit(1);
    }

    exit(0);
}

XSI共享内存跟mmap在实现上并没有本质区别。而之所以引入key和shmid的概念,也主要是为了在非父子关系的进程之间可以共享内存。根据上面的例子可以看到,使用shmget可以根据key创建共享内存,并返回一个shmid。它的第二个参数size用来指定共享内存段的长度,第三个参数指定创建的标志,可以支持的标志为:IPC_CREAT、IPC_EXCL。从Linux 2.6之后,还引入了支持大页的共享内存,标志为:SHM_HUGETLB、SHM_HUGE_2MB等参数。shmget除了可以创建一个新的共享内存以外,还可以访问一个已经存在的共享内存,此时可以将shmflg置为0,不加任何标识打开。

在某些情况下,我们也可以不用通过一个key来生成共享内存。此时可以在key的参数所在位置填:IPC_PRIVATE,这样内核会在保证不产生冲突的共享内存段id的情况下新建一段共享内存。当然,这样调用则必然意味着是新创建,而不是打开已有得共享内存,所以flag位一定是IPC_CREAT。此时产生的共享内存只有一个shmid,而没有key,所以可以通过fork的方式将id传给子进程。

当获得shmid之后,就可以使用shmat来进行地址映射。shmat之后,通过访问返回的当前进程的虚拟地址就可以访问到共享内存段了。当然,在使用之后要记得使用shmdt解除映射,否则对于长期运行的程序可能造成虚拟内存地址泄漏,导致没有可用地址可用。shmdt并不能删除共享内存段,而只是解除共享内存和进程虚拟地址的映射,只要shmid对应的共享内存还存在,就仍然可以继续使用shmat映射使用。想要删除一个共享内存需要使用shmctl的IPC_RMID指令处理。也可以在命令行中使用ipcrm删除指定的共享内存id或key。

共享内存由于其特性,与进程中的其他内存段在使用习惯上有些不同。一般进程对栈空间分配可以自动回收,而堆空间通过malloc申请,free回收。这些内存在回收之后就可以认为是不存在了。但是共享内存不同,用shmdt之后,实际上其占用的内存还在,并仍然可以使用shmat映射使用。如果不是用shmctl或ipcrm命令删除的话,那么它将一直保留直到系统被关闭。对于刚接触共享内存的程序员来说这可能需要适应一下。实际上共享内存的生存周期根文件更像:进程对文件描述符执行close并不能删除文件,而只是关闭了本进程对文件的操作接口,这就像shmdt的作用。而真正删除文件要用unlink,活着使用rm命令,这就像是共享内存的shmctl的IPC_RMID和ipcrm命令。当然,文件如果不删除,下次重启依旧还在,因为它放在硬盘上,而共享内存下次重启就没了,因为它毕竟还是内存。

在这里,请大家理解关于为什么要使用key,和相关共享内存id的概念。后续我们还将看到,除了共享内存外,XSI的信号量、消息队列也都是通过这种方式进行相关资源标识的。

除了可以删除一个共享内存以外,shmctl还可以查看、修改共享内存的相关属性。这些属性的细节大家可以man 2 shmctl查看细节帮助。在系统中还可以使用ipcs -m命令查看系统中所有共享内存的的信息,以及ipcrm指定删除共享内存。

这个例子最后执行如下:

[zorro@zorrozou-pc0 sharemem]$ ./racing_xsi_shm 
shm_p: 27
[zorro@zorrozou-pc0 sharemem]$ ./racing_xsi_shm 
shm_p: 22
[zorro@zorrozou-pc0 sharemem]$ ./racing_xsi_shm 
shm_p: 21
[zorro@zorrozou-pc0 sharemem]$ ./racing_xsi_shm 
shm_p: 20

到目前为止,我们仍然没解决racing的问题,所以得到的结果仍然是不确定的,我们会在讲解信号量的时候引入锁解决这个问题,当然也可以用文件锁。我们下面再修改刚才的mmap_mem.c程序,换做shm方式再来看看内存的使用情况,代码如下:

[zorro@zorrozou-pc0 sharemem]$ cat xsi_shm_mem.c 
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <wait.h>
#include <sys/mman.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>

#define COUNT 100
#define MEMSIZE 1024*1024*1023*2

int main()
{
    pid_t pid;
    int count, shm_id;
    void *shm_p;

    shm_id = shmget(IPC_PRIVATE, MEMSIZE, 0600);
    if (shm_id < 0) {
        perror("shmget()");
        exit(1);
    }


    shm_p = shmat(shm_id, NULL, 0);
    if ((void *)shm_p == (void *)-1) {
        perror("shmat()");
        exit(1);
    }

    bzero(shm_p, MEMSIZE);

    sleep(3000);

    if (shmdt(shm_p) < 0) {
        perror("shmdt()");
        exit(1);
    }

    if (shmctl(shm_id, IPC_RMID, NULL) < 0) {
        perror("shmctl()");
        exit(1);
    }

    exit(0);
}

我们在这段代码中使用了IPC_PRIVATE方式共享内存,这是与前面程序不一样的地方。执行结果为:

[zorro@zorrozou-pc0 sharemem]$ free -g
              total        used        free      shared  buff/cache   available
Mem:             15           2           2           0          10          11
Swap:            31           0          31
[zorro@zorrozou-pc0 sharemem]$ ./xsi_shm_mem &
[1] 4539
[zorro@zorrozou-pc0 sharemem]$ free -g
              total        used        free      shared  buff/cache   available
Mem:             15           2           0           2          12           9
Swap:            31           0          31

跟mmap的共享内存一样,XSI的共享内存在free现实中也会占用shared和buff/cache的消耗。实际上,在内核底层实现上,两种内存共享都是使用的tmpfs方式实现的,所以它们实际上的内存使用都是一致的。


如果觉得本文有用,请刷二维码任意捐助:收钱


对于Linux系统来说,使用XSI共享内存的时候可以通过shmget系统调用的shmflg参数来申请大页内存(huge pages),当然这样做将使进程的平台移植性变差。相关的参数包括:

SHM_HUGETLB (since Linux 2.6)

SHM_HUGE_2MB, SHM_HUGE_1GB (since Linux 3.8)

使用大页内存的好处是提高内核对内存管理的处理效率,这主要是因为在相同内存大小的情况下,使用大页内存(2M一页)将比使用一般内存页(4k一页)的内存页管理的数量大大减少,从而减少了内存页表项的缓存压力和CPU cache缓存内存地质映射的压力,提高了寻址能力和内存管理效率。大页内存还有其他一些使用时需要注意的地方:

  1. 大页内存不能交换(SWAP).
  2. 使用不当时可能造成更大的内存泄漏。

我们继续使用上面的程序修改改为使用大页内存来做一下测试,大页内存需要使用root权限,代码跟上面程序一样,只需要修改一下shmget的参数,如下:

[root@zorrozou-pc0 sharemem]# cat xsi_shm_mem_huge.c 
......
    shm_id = shmget(IPC_PRIVATE, MEMSIZE, SHM_HUGETLB|0600);
......

其余代码都不变。我们申请的内存大约不到2G,所以需要在系统内先给我们预留2G以上的大页内存:

[root@zorrozou-pc0 sharemem]# echo 2048 > /proc/sys/vm/nr_hugepages
[root@zorrozou-pc0 sharemem]# cat /proc/meminfo |grep -i huge
AnonHugePages:    841728 kB
HugePages_Total:    2020
HugePages_Free:     2020
HugePages_Rsvd:        0
HugePages_Surp:        0
Hugepagesize:       2048 kB

2048是页数,每页2M,所以这里预留了几乎4G的内存空间给大页。之后我么好还需要确保共享内存的限制不会使我们申请失败:

[root@zorrozou-pc0 sharemem]# echo 2147483648 > /proc/sys/kernel/shmmax
[root@zorrozou-pc0 sharemem]# echo 33554432 > /proc/sys/kernel/shmall

之后编译执行相关命令:

[root@zorrozou-pc0 sharemem]# echo 1 > /proc/sys/vm/drop_caches 
[root@zorrozou-pc0 sharemem]# free -g
              total        used        free      shared  buff/cache   available
Mem:             15           6           6           0           2           7
Swap:            31           0          31
[root@zorrozou-pc0 sharemem]# ./xsi_shm_mem_huge &
[1] 5508
[root@zorrozou-pc0 sharemem]# free -g
              total        used        free      shared  buff/cache   available
Mem:             15           6           6           0           2           7
Swap:            31           0          31
[root@zorrozou-pc0 sharemem]# cat /proc/meminfo |grep -i huge
AnonHugePages:    841728 kB
HugePages_Total:    2020
HugePages_Free:      997
HugePages_Rsvd:        0
HugePages_Surp:        0
Hugepagesize:       2048 kB

大家可以根据这个程序的输出看到,当前系统环境(archlinux kernel 4.5)再使用大页内存之后,free命令是看不见其内存统计的。同样的设置在Centos 7环境下也是相同的显示。这就是说,如果使用大页内存作为共享内存使用,将在free中看不到相关内存统计,这估计是free命令目前暂时没将大页内存统计进内存使用所导致,暂时大家只能通过/proc/meminfo文件中的相关统计看到大页内存的使用信息。

XSI共享内存的系统相关限制如下:

/proc/sys/kernel/shmall:限制系统用在共享内存上的内存总页数。注意是页数,单位为4k。

/proc/sys/kernel/shmmax:限制一个共享内存段的最大长度,字节为单位。

/proc/sys/kernel/shmmni:限制整个系统可创建的最大的共享内存段个数。

XSI共享内存是历史比较悠久,也比较经典的共享内存手段。它几乎代表了共享内存的默认定义,当我们说有共享内存的时候,一般意味着使用了XSI的共享内存。但是这种共享内存也存在一切缺点,最受病垢的地方莫过于他提供的key+projid的命名方式不够UNIX,没有遵循一切皆文件的设计理念。当然这个设计理念在一般的应用场景下并不是什么必须要遵守的理念,但是如果共享内存可以用文件描述符的方式提供给程序访问,毫无疑问可以在Linux上跟select、poll、epoll这样的IO异步事件驱动机制配合使用,做到一些更高级的功能。于是,遵循一切皆文件理念的POSIX标准的进程间通信机制应运而生。

POSIX共享内存

POSIX共享内存实际上毫无新意,它本质上就是mmap对文件的共享方式映射,只不过映射的是tmpfs文件系统上的文件。

什么是tmpfs?Linux提供一种“临时”文件系统叫做tmpfs,它可以将内存的一部分空间拿来当做文件系统使用,使内存空间可以当做目录文件来用。现在绝大多数Linux系统都有一个叫做/dev/shm的tmpfs目录,就是这样一种存在。具体使用方法,大家可以参考我的另一篇文章《Linux内存中的Cache真的能被回收么?》。

Linux提供的POSIX共享内存,实际上就是在/dev/shm下创建一个文件,并将其mmap之后映射其内存地址即可。我们通过它给定的一套参数就能猜到它的主要函数shm_open无非就是open系统调用的一个封装。大家可以通过man shm_overview来查看相关操作的方法。使用代码如下:

[root@zorrozou-pc0 sharemem]# cat racing_posix_shm.c 
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <wait.h>
#include <sys/mman.h>

#define COUNT 100
#define SHMPATH "shm"

int do_child(char * shmpath)
{
    int interval, shmfd, ret;
    int *shm_p;
    /* 使用shm_open访问一个已经创建的POSIX共享内存 */
    shmfd = shm_open(shmpath, O_RDWR, 0600);
    if (shmfd < 0) {
        perror("shm_open()");
        exit(1);
    }

    /* 使用mmap将对应的tmpfs文件映射到本进程内存 */
    shm_p = (int *)mmap(NULL, sizeof(int), PROT_WRITE|PROT_READ, MAP_SHARED, shmfd, 0);
    if (MAP_FAILED == shm_p) {
        perror("mmap()");
        exit(1);
    }
    /* critical section */
    interval = *shm_p;
    interval++;
    usleep(1);
    *shm_p = interval;
    /* critical section */
    munmap(shm_p, sizeof(int));
    close(shmfd);

    exit(0);
}

int main()
{
    pid_t pid;
    int count, shmfd, ret;
    int *shm_p;

    /* 创建一个POSIX共享内存 */
    shmfd = shm_open(SHMPATH, O_RDWR|O_CREAT|O_TRUNC, 0600);
    if (shmfd < 0) {
        perror("shm_open()");
        exit(1);
    }

    /* 使用ftruncate设置共享内存段大小 */
    ret = ftruncate(shmfd, sizeof(int));
    if (ret < 0) {
        perror("ftruncate()");
        exit(1);
    }

    /* 使用mmap将对应的tmpfs文件映射到本进程内存 */
    shm_p = (int *)mmap(NULL, sizeof(int), PROT_WRITE|PROT_READ, MAP_SHARED, shmfd, 0);
    if (MAP_FAILED == shm_p) {
        perror("mmap()");
        exit(1);
    }

    *shm_p = 0;

    for (count=0;count<COUNT;count++) {
        pid = fork();
        if (pid < 0) {
            perror("fork()");
            exit(1);
        }

        if (pid == 0) {
            do_child(SHMPATH);
        }
    }

    for (count=0;count<COUNT;count++) {
        wait(NULL);
    }

    printf("shm_p: %d\n", *shm_p);
    munmap(shm_p, sizeof(int));
    close(shmfd);
    //sleep(3000);
    shm_unlink(SHMPATH);
    exit(0);
}

编译执行这个程序需要指定一个额外rt的库,可以使用如下命令进行编译:

[root@zorrozou-pc0 sharemem]# gcc -o racing_posix_shm -lrt racing_posix_shm.c

对于这个程序,我们需要解释以下几点:

  1. shm_open的SHMPATH参数是一个路径,这个路径默认放在系统的/dev/shm目录下。这是shm_open已经封装好的,保证了文件一定会使用tmpfs。
  2. shm_open实际上就是open系统调用的封装。我们当然完全可以使用open的方式模拟这个方法。
  3. 使用ftruncate方法来设置“共享内存”的大小。其实就是更改文件的长度。
  4. 要以共享方式做mmap映射,并且指定文件描述符为shmfd。
  5. shm_unlink实际上就是unlink系统调用的封装。如果不做unlink操作,那么文件会一直存在于/dev/shm目录下,以供其它进程使用。
  6. 关闭共享内存描述符直接使用close。

以上就是POSIX共享内存。其本质上就是个tmpfs文件。那么从这个角度说,mmap匿名共享内存、XSI共享内存和POSIX共享内存在内核实现本质上其实都是tmpfs。如果我们去查看POSIX共享内存的free空间占用的话,结果将跟mmap和XSI共享内存一样占用shared和buff/cache,所以我们就不再做这个测试了。这部分内容大家也可以参考《Linux内存中的Cache真的能被回收么?》。

根据以上例子,我们整理一下POSIX共享内存的使用相关方法:

#include <sys/mman.h>
#include <sys/stat.h>        /* For mode constants */
#include <fcntl.h>           /* For O_* constants */

int shm_open(const char *name, int oflag, mode_t mode);

int shm_unlink(const char *name);

使用shm_open可以创建或者访问一个已经创建的共享内存。上面说过,实际上POSIX共享内存就是在/dev/shm目录中的的一个tmpfs格式的文件,所以shm_open无非就是open系统调用的封装,所以起函数使用的参数几乎一样。其返回的也是一个标准的我呢间描述符。

shm_unlink也一样是unlink调用的封装,用来删除文件名和文件的映射关系。在这就能看出POSIX共享内存和XSI的区别了,一个是使用文件名作为全局标识,另一个是使用key。

映射共享内存地址使用mmap,解除映射使用munmap。使用ftruncate设置共享内存大小,实际上就是对tmpfs的文件进行指定长度的截断。使用fchmod、fchown、fstat等系统调用修改和查看相关共享内存的属性。close调用关闭共享内存的描述符。实际上,这都是标准的文件操作。

最后

希望这些内容对大家进一步深入了共享内存有帮助。如果有相关问题,可以在我的微博、微信或者博客上联系我。

如果觉得本文有用,请刷二维码任意捐助:

收钱


大家好,我是Zorro!

如果你喜欢本文,欢迎在微博上搜索“orroz”关注我,地址是:http://weibo.com/orroz

大家也可以在微信上搜索:Linux系统技术 关注我的公众号。

我的所有文章都会沉淀在我的个人博客上,地址是:http://liwei.life。

欢迎使用以上各种方式一起探讨学习,共同进步。

公众号二维码:

Zorro] icon


 

Linux的进程间通信-文件和文件锁

Linux的进程间通信-文件和文件锁

版权声明:

本文章内容在非商业使用前提下可无需授权任意转载、发布。

转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:orroz

微信公众号:Linux系统技术

前言

使用文件进行进程间通信应该是最先学会的一种IPC方式。任何编程语言中,文件IO都是很重要的知识,所以使用文件进行进程间通信就成了很自然被学会的一种手段。考虑到系统对文件本身存在缓存机制,使用文件进行IPC的效率在某些多读少写的情况下并不低下。但是大家似乎经常忘记IPC的机制可以包括“文件”这一选项。

我们首先引入文件进行IPC,试图先使用文件进行通信引入一个竞争条件的概念,然后使用文件锁解决这个问题,从而先从文件的角度来管中窥豹的看一下后续相关IPC机制的总体要解决的问题。阅读本文可以帮你解决以下问题:

  1. 什么是竞争条件(racing)?。
  2. flock和lockf有什么区别?
  3. flockfile函数和flock与lockf有什么区别?
  4. 如何使用命令查看文件锁?

如果您觉得本文有用,请刷二维码随意捐助。

收钱

竞争条件(racing)

我们的第一个例子是多个进程写文件的例子,虽然还没做到通信,但是这比较方便的说明一个通信时经常出现的情况:竞争条件。假设我们要并发100个进程,这些进程约定好一个文件,这个文件初始值内容写0,每一个进程都要打开这个文件读出当前的数字,加一之后将结果写回去。在理想状态下,这个文件最后写的数字应该是100,因为有100个进程打开、读数、加1、写回,自然是有多少个进程最后文件中的数字结果就应该是多少。但是实际上并非如此,可以看一下这个例子:

[zorro@zorrozou-pc0 process]$ cat racing.c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <wait.h>

#define COUNT 100
#define NUM 64
#define FILEPATH "/tmp/count"

int do_child(const char *path)
{
    /* 这个函数是每个子进程要做的事情
    每个子进程都会按照这个步骤进行操作:
    1. 打开FILEPATH路径的文件
    2. 读出文件中的当前数字
    3. 将字符串转成整数
    4. 整数自增加1
    5. 将证书转成字符串
    6. lseek调整文件当前的偏移量到文件头
    7. 将字符串写会文件
    当多个进程同时执行这个过程的时候,就会出现racing:竞争条件,
    多个进程可能同时从文件独到同一个数字,并且分别对同一个数字加1并写回,
    导致多次写回的结果并不是我们最终想要的累积结果。 */
    int fd;
    int ret, count;
    char buf[NUM];
    fd = open(path, O_RDWR);
    if (fd < 0) {
        perror("open()");
        exit(1);
    }
    /*  */
    ret = read(fd, buf, NUM);
    if (ret < 0) {
        perror("read()");
        exit(1);
    }
    buf[ret] = '\0';
    count = atoi(buf);
    ++count;
    sprintf(buf, "%d", count);
    lseek(fd, 0, SEEK_SET);
    ret = write(fd, buf, strlen(buf));
    /*  */
    close(fd);
    exit(0);
}

int main()
{
    pid_t pid;
    int count;

    for (count=0;count<COUNT;count++) {
        pid = fork();
        if (pid < 0) {
            perror("fork()");
            exit(1);
        }

        if (pid == 0) {
            do_child(FILEPATH);
        }
    }

    for (count=0;count<COUNT;count++) {
        wait(NULL);
    }
}

这个程序做后执行的效果如下:

[zorro@zorrozou-pc0 process]$ make racing
cc     racing.c   -o racing
[zorro@zorrozou-pc0 process]$ echo 0 > /tmp/count
[zorro@zorrozou-pc0 process]$ ./racing 
[zorro@zorrozou-pc0 process]$ cat /tmp/count 
71[zorro@zorrozou-pc0 process]$ 
[zorro@zorrozou-pc0 process]$ echo 0 > /tmp/count
[zorro@zorrozou-pc0 process]$ ./racing 
[zorro@zorrozou-pc0 process]$ cat /tmp/count 
61[zorro@zorrozou-pc0 process]$ 
[zorro@zorrozou-pc0 process]$ echo 0 > /tmp/count
[zorro@zorrozou-pc0 process]$ ./racing 
[zorro@zorrozou-pc0 process]$ cat /tmp/count 
64[zorro@zorrozou-pc0 process]$ 

我们执行了三次这个程序,每次结果都不太一样,第一次是71,第二次是61,第三次是64,全都没有得到预期结果,这就是竞争条件(racing)引入的问题。仔细分析这个进程我们可以发现这个竞争条件是如何发生的:

最开始文件内容是0,假设此时同时打开了3个进程,那么他们分别读文件的时候,这个过程是可能并发的,于是每个进程读到的数组可能都是0,因为他们都在别的进程没写入1之前就开始读了文件。于是三个进程都是给0加1,然后写了个1回到文件。其他进程以此类推,每次100个进程的执行顺序可能不一样,于是结果是每次得到的值都可能不太一样,但是一定都少于产生的实际进程个数。于是我们把这种多个执行过程(如进程或线程)中访问同一个共享资源,而这些共享资源又有无法被多个执行过程存取的的程序片段,叫做临界区代码。

那么该如何解决这个racing的问题呢?对于这个例子来说,可以用文件锁的方式解决这个问题。就是说,对临界区代码进行加锁,来解决竞争条件的问题。哪段是临界区代码?在这个例子中,两端/ /之间的部分就是临界区代码。一个正确的例子是:

...
    ret = flock(fd, LOCK_EX);
    if (ret == -1) {
        perror("flock()");
        exit(1);
    }

    ret = read(fd, buf, NUM);
    if (ret < 0) {
        perror("read()");
        exit(1);
    }
    buf[ret] = '\0';
    count = atoi(buf);
    ++count;
    sprintf(buf, "%d", count);
    lseek(fd, 0, SEEK_SET);
    ret = write(fd, buf, strlen(buf));
    ret = flock(fd, LOCK_UN);
    if (ret == -1) {
        perror("flock()");
        exit(1);
    }
...

我们将临界区部分代码前后都使用了flock的互斥锁,防止了临界区的racing。这个例子虽然并没有真正达到让多个进程通过文件进行通信,解决某种协同工作问题的目的,但是足以表现出进程间通信机制的一些问题了。当涉及到数据在多个进程间进行共享的时候,仅仅只实现数据通信或共享机制本身是不够的,还需要实现相关的同步或异步机制来控制多个进程,达到保护临界区或其他让进程可以处理同步或异步事件的能力。我们可以认为文件锁是可以实现这样一种多进程的协调同步能力的机制,而除了文件锁以外,还有其他机制可以达到相同或者不同的功能,我们会在下文中继续详细解释。

再次,我们并不对flock这个方法本身进行功能性讲解。这种功能性讲解大家可以很轻易的在网上或者通过别的书籍得到相关内容。本文更加偏重的是Linux环境提供了多少种文件锁以及他们的区别是什么?

flock和lockf

从底层的实现来说,Linux的文件锁主要有两种:flock和lockf。需要额外对lockf说明的是,它只是fcntl系统调用的一个封装。从使用角度讲,lockf或fcntl实现了更细粒度文件锁,即:记录锁。我们可以使用lockf或fcntl对文件的部分字节上锁,而flock只能对整个文件加锁。这两种文件锁是从历史上不同的标准中起源的,flock来自BSD而lockf来自POSIX,所以lockf或fcntl实现的锁在类型上又叫做POSIX锁。

除了这个区别外,fcntl系统调用还可以支持强制锁(Mandatory locking)。强制锁的概念是传统UNIX为了强制应用程序遵守锁规则而引入的一个概念,与之对应的概念就是建议锁(Advisory locking)。我们日常使用的基本都是建议锁,它并不强制生效。这里的不强制生效的意思是,如果某一个进程对一个文件持有一把锁之后,其他进程仍然可以直接对文件进行各种操作的,比如open、read、write。只有当多个进程在操作文件前都去检查和对相关锁进行锁操作的时候,文件锁的规则才会生效。这就是一般建议锁的行为。而强制性锁试图实现一套内核级的锁操作。当有进程对某个文件上锁之后,其他进程即使不在操作文件之前检查锁,也会在open、read或write等文件操作时发生错误。内核将对有锁的文件在任何情况下的锁规则都生效,这就是强制锁的行为。由此可以理解,如果内核想要支持强制锁,将需要在内核实现open、read、write等系统调用内部进行支持。

从应用的角度来说,Linux内核虽然号称具备了强制锁的能力,但其对强制性锁的实现是不可靠的,建议大家还是不要在Linux下使用强制锁。事实上,在我目前手头正在使用的Linux环境上,一个系统在mount -o mand分区的时候报错(archlinux kernel 4.5),而另一个系统虽然可以以强制锁方式mount上分区,但是功能实现却不完整,主要表现在只有在加锁后产生的子进程中open才会报错,如果直接write是没问题的,而且其他进程无论open还是read、write都没问题(Centos 7 kernel 3.10)。鉴于此,我们就不在此介绍如何在Linux环境中打开所谓的强制锁支持了。我们只需知道,在Linux环境下的应用程序,flock和lockf在是锁类型方面没有本质差别,他们都是建议锁,而非强制锁。

flock和lockf另外一个差别是它们实现锁的方式不同。这在应用的时候表现在flock的语义是针对文件的锁,而lockf是针对文件描述符(fd)的锁。我们用一个例子来观察这个区别:

[zorro@zorrozou-pc0 locktest]$ cat flock.c
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <wait.h>

#define PATH "/tmp/lock"

int main()
{
    int fd;
    pid_t pid;

    fd = open(PATH, O_RDWR|O_CREAT|O_TRUNC, 0644);
    if (fd < 0) {
        perror("open()");
        exit(1);
    }

    if (flock(fd, LOCK_EX) < 0) {
        perror("flock()");
        exit(1);
    }
    printf("%d: locked!\n", getpid());

    pid = fork();
    if (pid < 0) {
        perror("fork()");
        exit(1);
    }

    if (pid == 0) {
/*
        fd = open(PATH, O_RDWR|O_CREAT|O_TRUNC, 0644);
        if (fd < 0) {
                perror("open()");
                exit(1);
        }
*/
        if (flock(fd, LOCK_EX) < 0) {
            perror("flock()");
            exit(1);
        }
        printf("%d: locked!\n", getpid());
        exit(0);
    }
    wait(NULL);
    unlink(PATH);
    exit(0);
}

上面代码是一个flock的例子,其作用也很简单:

  1. 打开/tmp/lock文件。
  2. 使用flock对其加互斥锁。
  3. 打印“PID:locked!”表示加锁成功。
  4. 打开一个子进程,在子进程中使用flock对同一个文件加互斥锁。
  5. 子进程打印“PID:locked!”表示加锁成功。如果没加锁成功子进程会推出,不显示相关内容。
  6. 父进程回收子进程并推出。

这个程序直接编译执行的结果是:

[zorro@zorrozou-pc0 locktest]$ ./flock 
23279: locked!
23280: locked!

父子进程都加锁成功了。这个结果似乎并不符合我们对文件加锁的本意。按照我们对互斥锁的理解,子进程对父进程已经加锁过的文件应该加锁失败才对。我们可以稍微修改一下上面程序让它达到预期效果,将子进程代码段中的注释取消掉重新编译即可:

...
/*
        fd = open(PATH, O_RDWR|O_CREAT|O_TRUNC, 0644);
        if (fd < 0) {
                perror("open()");
                exit(1);
        }
*/
...

将这段代码上下的/ /删除重新编译。之后执行的效果如下:

[zorro@zorrozou-pc0 locktest]$ make flock
cc     flock.c   -o flock
[zorro@zorrozou-pc0 locktest]$ ./flock 
23437: locked!

此时子进程flock的时候会阻塞,让进程的执行一直停在这。这才是我们使用文件锁之后预期该有的效果。而相同的程序使用lockf却不会这样。这个原因在于flock和lockf的语义是不同的。使用lockf或fcntl的锁,在实现上关联到文件结构体,这样的实现导致锁不会在fork之后被子进程继承。而flock在实现上关联到的是文件描述符,这就意味着如果我们在进程中复制了一个文件描述符,那么使用flock对这个描述符加的锁也会在新复制出的描述符中继续引用。在进程fork的时候,新产生的子进程的描述符也是从父进程继承(复制)来的。在子进程刚开始执行的时候,父子进程的描述符关系实际上跟在一个进程中使用dup复制文件描述符的状态一样(参见《UNIX环境高级编程》8.3节的文件共享部分)。这就可能造成上述例子的情况,通过fork产生的多个进程,因为子进程的文件描述符是复制的父进程的文件描述符,所以导致父子进程同时持有对同一个文件的互斥锁,导致第一个例子中的子进程仍然可以加锁成功。这个文件共享的现象在子进程使用open重新打开文件之后就不再存在了,所以重新对同一文件open之后,子进程再使用flock进行加锁的时候会阻塞。另外要注意:除非文件描述符被标记了close-on-exec标记,flock锁和lockf锁都可以穿越exec,在当前进程变成另一个执行镜像之后仍然保留。

上面的例子中只演示了fork所产生的文件共享对flock互斥锁的影响,同样原因也会导致dup或dup2所产生的文件描述符对flock在一个进程内产生相同的影响。dup造成的锁问题一般只有在多线程情况下才会产生影响,所以应该避免在多线程场景下使用flock对文件加锁,而lockf/fcntl则没有这个问题。


如果您觉得本文有用,请刷二维码随意捐助。

收钱


为了对比flock的行为,我们在此列出使用lockf的相同例子,来演示一下它们的不同:

[zorro@zorrozou-pc0 locktest]$ cat lockf.c
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/file.h>
#include <wait.h>

#define PATH "/tmp/lock"

int main()
{
    int fd;
    pid_t pid;

    fd = open(PATH, O_RDWR|O_CREAT|O_TRUNC, 0644);
    if (fd < 0) {
        perror("open()");
        exit(1);
    }

    if (lockf(fd, F_LOCK, 0) < 0) {
        perror("lockf()");
        exit(1);
    }
    printf("%d: locked!\n", getpid());

    pid = fork();
    if (pid < 0) {
        perror("fork()");
        exit(1);
    }

    if (pid == 0) {
/*
        fd = open(PATH, O_RDWR|O_CREAT|O_TRUNC, 0644);
        if (fd < 0) {
            perror("open()");
            exit(1);
        }
*/
        if (lockf(fd, F_LOCK, 0) < 0) {
            perror("lockf()");
            exit(1);
        }
        printf("%d: locked!\n", getpid());
        exit(0);
    }
    wait(NULL);
    unlink(PATH);   
    exit(0);
}

编译执行的结果是:

[zorro@zorrozou-pc0 locktest]$ ./lockf 
27262: locked!

在子进程不用open重新打开文件的情况下,进程执行仍然被阻塞在子进程lockf加锁的操作上。关于fcntl对文件实现记录锁的详细内容,大家可以参考《UNIX环境高级编程》中关于记录锁的14.3章节。

标准IO库文件锁

C语言的标准IO库中还提供了一套文件锁,它们的原型如下:

#include <stdio.h>

void flockfile(FILE *filehandle);
int ftrylockfile(FILE *filehandle);
void funlockfile(FILE *filehandle);

从实现角度来说,stdio库中实现的文件锁与flock或lockf有本质区别。作为一种标准库,其实现的锁必然要考虑跨平台的特性,所以其结构都是在用户态的FILE结构体中实现的,而非内核中的数据结构来实现。这直接导致的结果就是,标准IO的锁在多进程环境中使用是有问题的。进程在fork的时候会复制一整套父进程的地址空间,这将导致子进程中的FILE结构与父进程完全一致。就是说,父进程如果加锁了,子进程也将持有这把锁,父进程没加锁,子进程由于地址空间跟父进程是独立的,所以也无法通过FILE结构体检查别的进程的用户态空间是否家了标准IO库提供的文件锁。这种限制导致这套文件锁只能处理一个进程中的多个线程之间共享的FILE 的进行文件操作。就是说,多个线程必须同时操作一个用fopen打开的FILE 变量,如果内部自己使用fopen重新打开文件,那么返回的FILE *地址不同,也起不到线程的互斥作用。

我们分别将两种使用线程的状态的例子分别列出来,第一种是线程之间共享同一个FILE *的情况,这种情况互斥是没问题的:

[zorro@zorro-pc locktest]$ cat racing_pthread_sharefp.c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <wait.h>
#include <pthread.h>

#define COUNT 100
#define NUM 64
#define FILEPATH "/tmp/count"
static FILE *filep;

void *do_child(void *p)
{
    int fd;
    int ret, count;
    char buf[NUM];

    flockfile(filep);

    if (fseek(filep, 0L, SEEK_SET) == -1) {
        perror("fseek()");
    }
    ret = fread(buf, NUM, 1, filep);

    count = atoi(buf);
    ++count;
    sprintf(buf, "%d", count);
    if (fseek(filep, 0L, SEEK_SET) == -1) {
        perror("fseek()");
    }
    ret = fwrite(buf, strlen(buf), 1, filep);

    funlockfile(filep);

    return NULL;
}

int main()
{
    pthread_t tid[COUNT];
    int count;

    filep = fopen(FILEPATH, "r+");
    if (filep == NULL) {
        perror("fopen()");
        exit(1);
    }

    for (count=0;count<COUNT;count++) {
        if (pthread_create(tid+count, NULL, do_child, NULL) != 0) {
            perror("pthread_create()");
            exit(1);
        }
    }

    for (count=0;count<COUNT;count++) {
        if (pthread_join(tid[count], NULL) != 0) {
            perror("pthread_join()");
            exit(1);
        }
    }

    fclose(filep);

    exit(0);
}

另一种情况是每个线程都fopen重新打开一个描述符,此时线程是不能互斥的:

[zorro@zorro-pc locktest]$ cat racing_pthread_threadfp.c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <string.h>
#include <sys/file.h>
#include <wait.h>
#include <pthread.h>

#define COUNT 100
#define NUM 64
#define FILEPATH "/tmp/count"

void *do_child(void *p)
{
    int fd;
    int ret, count;
    char buf[NUM];
    FILE *filep;

    filep = fopen(FILEPATH, "r+");
    if (filep == NULL) {
        perror("fopen()");
        exit(1);
    }

    flockfile(filep);

    if (fseek(filep, 0L, SEEK_SET) == -1) {
        perror("fseek()");
    }
    ret = fread(buf, NUM, 1, filep);

    count = atoi(buf);
    ++count;
    sprintf(buf, "%d", count);
    if (fseek(filep, 0L, SEEK_SET) == -1) {
        perror("fseek()");
    }
    ret = fwrite(buf, strlen(buf), 1, filep);

    funlockfile(filep);

    fclose(filep);
    return NULL;
}

int main()
{
    pthread_t tid[COUNT];
    int count;


    for (count=0;count<COUNT;count++) {
        if (pthread_create(tid+count, NULL, do_child, NULL) != 0) {
            perror("pthread_create()");
            exit(1);
        }
    }

    for (count=0;count<COUNT;count++) {
        if (pthread_join(tid[count], NULL) != 0) {
            perror("pthread_join()");
            exit(1);
        }
    }


    exit(0);
}

以上程序大家可以自行编译执行看看效果。

文件锁相关命令

系统为我们提供了flock命令,可以方便我们在命令行和shell脚本中使用文件锁。需要注意的是,flock命令是使用flock系统调用实现的,所以在使用这个命令的时候请注意进程关系对文件锁的影响。flock命令的使用方法和在脚本编程中的使用可以参见我的另一篇文章《shell编程之常用技巧》中的bash并发编程和flock这部分内容,在此不在赘述。

我们还可以使用lslocks命令来查看当前系统中的文件锁使用情况。一个常见的现实如下:

[root@zorrozou-pc0 ~]# lslocks 
COMMAND           PID   TYPE  SIZE MODE  M      START        END PATH
firefox         16280  POSIX    0B WRITE 0          0          0 /home/zorro/.mozilla/firefox/bk2bfsto.default/.parentlock
dmeventd          344  POSIX    4B WRITE 0          0          0 /run/dmeventd.pid
gnome-shell       472  FLOCK    0B WRITE 0          0          0 /run/user/120/wayland-0.lock
flock           27452  FLOCK    0B WRITE 0          0          0 /tmp/lock
lvmetad           248  POSIX    4B WRITE 0          0          0 /run/lvmetad.pid

这其中,TYPE主要表示锁类型,就是上文我们描述的flock和lockf。lockf和fcntl实现的锁事POSIX类型。M表示是否事强制锁,0表示不是。如果是记录锁的话,START和END表示锁住文件的记录位置,0表示目前锁住的是整个文件。MODE主要用来表示锁的权限,实际上这也说明了锁的共享属性。在系统底层,互斥锁表示为WRITE,而共享锁表示为READ,如果这段出现*则表示有其他进程正在等待这个锁。其余参数可以参考man lslocks。

最后

本文通过文件盒文件锁的例子,引出了竞争条件这样在进程间通信中需要解决的问题。并深入探讨了系统编程中常用的文件锁的实现和应用特点。希望大家对进程间通信和文件锁的使用有更深入的理解。

如果您觉得本文有用,请刷二维码随意捐助。

收钱


大家好,我是Zorro!

如果你喜欢本文,欢迎在微博上搜索“orroz”关注我,地址是:http://weibo.com/orroz

大家也可以在微信上搜索:Linux系统技术 关注我的公众号。

我的所有文章都会沉淀在我的个人博客上,地址是:http://liwei.life。

欢迎使用以上各种方式一起探讨学习,共同进步。

公众号二维码:

Zorro] icon


 

Linux的进程间通信 - 管道

Linux的进程间通信 - 管道

版权声明:

本文章内容在非商业使用前提下可无需授权任意转载、发布。

转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:orroz

微信公众号:Linux系统技术

前言

管道是UNIX环境中历史最悠久的进程间通信方式。本文主要说明在Linux环境上如何使用管道。阅读本文可以帮你解决以下问题:

  1. 什么是管道和为什么要有管道?
  2. 管道怎么分类?
  3. 管道的实现是什么样的?
  4. 管道有多大?
  5. 管道的大小是不是可以调整?如何调整?

如果觉得本文还不错,请扫码任意打赏捐助穷佐罗。

收钱

什么是管道?

管道,英文为pipe。这是一个我们在学习Linux命令行的时候就会引入的一个很重要的概念。它的发明人是道格拉斯.麦克罗伊,这位也是UNIX上早期shell的发明人。他在发明了shell之后,发现系统操作执行命令的时候,经常有需求要将一个程序的输出交给另一个程序进行处理,这种操作可以使用输入输出重定向加文件搞定,比如:

[zorro@zorro-pc pipe]$ ls  -l /etc/ > etc.txt
[zorro@zorro-pc pipe]$ wc -l etc.txt 
183 etc.txt

但是这样未免显得太麻烦了。所以,管道的概念应运而生。目前在任何一个shell中,都可以使用“|”连接两个命令,shell会将前后两个进程的输入输出用一个管道相连,以便达到进程间通信的目的:

[zorro@zorro-pc pipe]$ ls -l /etc/ | wc -l
183

对比以上两种方法,我们也可以理解为,管道本质上就是一个文件,前面的进程以写方式打开文件,后面的进程以读方式打开。这样前面写完后面读,于是就实现了通信。实际上管道的设计也是遵循UNIX的“一切皆文件”设计原则的,它本质上就是一个文件。Linux系统直接把管道实现成了一种文件系统,借助VFS给应用程序提供操作接口。

虽然实现形态上是文件,但是管道本身并不占用磁盘或者其他外部存储的空间。在Linux的实现上,它占用的是内存空间。所以,Linux上的管道就是一个操作方式为文件的内存缓冲区。

管道的分类和使用

Linux上的管道分两种类型:

  1. 匿名管道
  2. 命名管道

这两种管道也叫做有名或无名管道。匿名管道最常见的形态就是我们在shell操作中最常用的”|”。它的特点是只能在父子进程中使用,父进程在产生子进程前必须打开一个管道文件,然后fork产生子进程,这样子进程通过拷贝父进程的进程地址空间获得同一个管道文件的描述符,以达到使用同一个管道通信的目的。此时除了父子进程外,没人知道这个管道文件的描述符,所以通过这个管道中的信息无法传递给其他进程。这保证了传输数据的安全性,当然也降低了管道了通用性,于是系统还提供了命名管道。

我们可以使用mkfifo或mknod命令来创建一个命名管道,这跟创建一个文件没有什么区别:

[zorro@zorro-pc pipe]$ mkfifo pipe
[zorro@zorro-pc pipe]$ ls -l pipe 
prw-r--r-- 1 zorro zorro 0 Jul 14 10:44 pipe

可以看到创建出来的文件类型比较特殊,是p类型。表示这是一个管道文件。有了这个管道文件,系统中就有了对一个管道的全局名称,于是任何两个不相关的进程都可以通过这个管道文件进行通信了。比如我们现在让一个进程写这个管道文件:

[zorro@zorro-pc pipe]$ echo xxxxxxxxxxxxxx > pipe 

此时这个写操作会阻塞,因为管道另一端没有人读。这是内核对管道文件定义的默认行为。此时如果有进程读这个管道,那么这个写操作的阻塞才会解除:

[zorro@zorro-pc pipe]$ cat pipe 
xxxxxxxxxxxxxx

大家可以观察到,当我们cat完这个文件之后,另一端的echo命令也返回了。这就是命名管道。

Linux系统无论对于命名管道和匿名管道,底层都用的是同一种文件系统的操作行为,这种文件系统叫pipefs。大家可以在/etc/proc/filesystems文件中找到你的系统是不是支持这种文件系统:

[zorro@zorro-pc pipe]$ cat /proc/filesystems |grep pipefs
nodev   pipefs

观察完了如何在命令行中使用管道之后,我们再来看看如何在系统编程中使用管道。

PIPE

我们可以把匿名管道和命名管道分别叫做PIPE和FIFO。这主要因为在系统编程中,创建匿名管道的系统调用是pipe(),而创建命名管道的函数是mkfifo()。使用mknod()系统调用并指定文件类型为为S_IFIFO也可以创建一个FIFO。

使用pipe()系统调用可以创建一个匿名管道,这个系统调用的原型为:

#include <unistd.h>

int pipe(int pipefd[2]);

这个方法将会创建出两个文件描述符,可以使用pipefd这个数组来引用这两个描述符进行文件操作。pipefd[0]是读方式打开,作为管道的读描述符。pipefd[1]是写方式打开,作为管道的写描述符。从管道写端写入的数据会被内核缓存直到有人从另一端读取为止。我们来看一下如何在一个进程中使用管道,虽然这个例子并没有什么意义:

[zorro@zorro-pc pipe]$ cat pipe.c
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>

#define STRING "hello world!"

int main()
{
    int pipefd[2];
    char buf[BUFSIZ];

    if (pipe(pipefd) == -1) {
        perror("pipe()");
        exit(1);
    }

    if (write(pipefd[1], STRING, strlen(STRING)) < 0) {
        perror("write()");
        exit(1);
    }

    if (read(pipefd[0], buf, BUFSIZ) < 0) {
        perror("write()");
        exit(1);
    }

    printf("%s\n", buf);

    exit(0);
}

这个程序创建了一个管道,并且对管道写了一个字符串之后从管道读取,并打印在标准输出上。用一个图来说明这个程序的状态就是这样的:

pipe1

一个进程自己给自己发送消息这当然不叫进程间通信,所以实际情况中我们不会在单个进程中使用管道。进程在pipe创建完管道之后,往往都要fork产生子进程,成为如下图表示的样子:

pipe2

如图中描述,fork产生的子进程会继承父进程对应的文件描述符。利用这个特性,父进程先pipe创建管道之后,子进程也会得到同一个管道的读写文件描述符。从而实现了父子两个进程使用一个管道可以完成半双工通信。此时,父进程可以通过fd[1]给子进程发消息,子进程通过fd[0]读。子进程也可以通过fd[1]给父进程发消息,父进程用fd[0]读。程序实例如下:

[zorro@zorro-pc pipe]$ cat pipe_parent_child.c
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>

#define STRING "hello world!"

int main()
{
    int pipefd[2];
    pid_t pid;
    char buf[BUFSIZ];

    if (pipe(pipefd) == -1) {
        perror("pipe()");
        exit(1);
    }

    pid = fork();
    if (pid == -1) {
        perror("fork()");
        exit(1);
    }

    if (pid == 0) {
        /* this is child. */
        printf("Child pid is: %d\n", getpid());
        if (read(pipefd[0], buf, BUFSIZ) < 0) {
            perror("write()");
            exit(1);
        }

        printf("%s\n", buf);

        bzero(buf, BUFSIZ);
        snprintf(buf, BUFSIZ, "Message from child: My pid is: %d", getpid());
        if (write(pipefd[1], buf, strlen(buf)) < 0) {
            perror("write()");
            exit(1);
        }

    } else {
        /* this is parent */
        printf("Parent pid is: %d\n", getpid());

        snprintf(buf, BUFSIZ, "Message from parent: My pid is: %d", getpid());
        if (write(pipefd[1], buf, strlen(buf)) < 0) {
            perror("write()");
            exit(1);
        }

        sleep(1);

        bzero(buf, BUFSIZ);
        if (read(pipefd[0], buf, BUFSIZ) < 0) {
            perror("write()");
            exit(1);
        }

        printf("%s\n", buf);

        wait(NULL);
    }


    exit(0);
}

父进程先给子进程发一个消息,子进程接收到之后打印消息,之后再给父进程发消息,父进程再打印从子进程接收到的消息。程序执行效果:

[zorro@zorro-pc pipe]$ ./pipe_parent_child 
Parent pid is: 8309
Child pid is: 8310
Message from parent: My pid is: 8309
Message from child: My pid is: 8310

从这个程序中我们可以看到,管道实际上可以实现一个半双工通信的机制。使用同一个管道的父子进程可以分时给对方发送消息。我们也可以看到对管道读写的一些特点,即:

在管道中没有数据的情况下,对管道的读操作会阻塞,直到管道内有数据为止。当一次写的数据量不超过管道容量的时候,对管道的写操作一般不会阻塞,直接将要写的数据写入管道缓冲区即可。


如果觉得本文还不错,请扫码任意打赏捐助穷佐罗。

收钱


当然写操作也不会再所有情况下都不阻塞。这里我们要先来了解一下管道的内核实现。上文说过,管道实际上就是内核控制的一个内存缓冲区,既然是缓冲区,就有容量上限。我们把管道一次最多可以缓存的数据量大小叫做PIPESIZE。内核在处理管道数据的时候,底层也要调用类似read和write这样的方法进行数据拷贝,这种内核操作每次可以操作的数据量也是有限的,一般的操作长度为一个page,即默认为4k字节。我们把每次可以操作的数据量长度叫做PIPEBUF。POSIX标准中,对PIPEBUF有长度限制,要求其最小长度不得低于512字节。PIPEBUF的作用是,内核在处理管道的时候,如果每次读写操作的数据长度不大于PIPEBUF时,保证其操作是原子的。而PIPESIZE的影响是,大于其长度的写操作会被阻塞,直到当前管道中的数据被读取为止。

在Linux 2.6.11之前,PIPESIZE和PIPEBUF实际上是一样的。在这之后,Linux重新实现了一个管道缓存,并将它与写操作的PIPEBUF实现成了不同的概念,形成了一个默认长度为65536字节的PIPESIZE,而PIPEBUF只影响相关读写操作的原子性。从Linux 2.6.35之后,在fcntl系统调用方法中实现了F_GETPIPE_SZ和F_SETPIPE_SZ操作,来分别查看当前管道容量和设置管道容量。管道容量容量上限可以在/proc/sys/fs/pipe-max-size进行设置。

#define BUFSIZE 65536

......

ret = fcntl(pipefd[1], F_GETPIPE_SZ);
if (ret < 0) {
    perror("fcntl()");
    exit(1);
}

printf("PIPESIZE: %d\n", ret);

ret = fcntl(pipefd[1], F_SETPIPE_SZ, BUFSIZE);
if (ret < 0) {
    perror("fcntl()");
    exit(1);
}

......

PIPEBUF和PIPESIZE对管道操作的影响会因为管道描述符是否被设置为非阻塞方式而有行为变化,n为要写入的数据量时具体为:

O_NONBLOCK关闭,n <= PIPE_BUF:

n个字节的写入操作是原子操作,write系统调用可能会因为管道容量(PIPESIZE)没有足够的空间存放n字节长度而阻塞。

O_NONBLOCK打开,n <= PIPE_BUF:

如果有足够的空间存放n字节长度,write调用会立即返回成功,并且对数据进行写操作。空间不够则立即报错返回,并且errno被设置为EAGAIN。

O_NONBLOCK关闭,n > PIPE_BUF:

对n字节的写入操作不保证是原子的,就是说这次写入操作的数据可能会跟其他进程写这个管道的数据进行交叉。当管道容量长度低于要写的数据长度的时候write操作会被阻塞。

O_NONBLOCK打开,n > PIPE_BUF:

如果管道空间已满。write调用报错返回并且errno被设置为EAGAIN。如果没满,则可能会写入从1到n个字节长度,这取决于当前管道的剩余空间长度,并且这些数据可能跟别的进程的数据有交叉。

以上是在使用半双工管道的时候要注意的事情,因为在这种情况下,管道的两端都可能有多个进程进行读写处理。如果再加上线程,则事情可能变得更复杂。实际上,我们在使用管道的时候,并不推荐这样来用。管道推荐的使用方法是其单工模式:即只有两个进程通信,一个进程只写管道,另一个进程只读管道。实现为:

[zorro@zorro-pc pipe]$ cat pipe_parent_child2.c
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>

#define STRING "hello world!"

int main()
{
    int pipefd[2];
    pid_t pid;
    char buf[BUFSIZ];

    if (pipe(pipefd) == -1) {
        perror("pipe()");
        exit(1);
    }

    pid = fork();
    if (pid == -1) {
        perror("fork()");
        exit(1);
    }

    if (pid == 0) {
        /* this is child. */
        close(pipefd[1]);

        printf("Child pid is: %d\n", getpid());
        if (read(pipefd[0], buf, BUFSIZ) < 0) {
            perror("write()");
            exit(1);
        }

        printf("%s\n", buf);

    } else {
        /* this is parent */
        close(pipefd[0]);

        printf("Parent pid is: %d\n", getpid());

        snprintf(buf, BUFSIZ, "Message from parent: My pid is: %d", getpid());
        if (write(pipefd[1], buf, strlen(buf)) < 0) {
            perror("write()");
            exit(1);
        }

        wait(NULL);
    }


    exit(0);
}

这个程序实际上比上一个要简单,父进程关闭管道的读端,只写管道。子进程关闭管道的写端,只读管道。整个管道的打开效果最后成为下图所示:

pipe3

此时两个进程就只用管道实现了一个单工通信,并且这种状态下不用考虑多个进程同时对管道写产生的数据交叉的问题,这是最经典的管道打开方式,也是我们推荐的管道使用方式。另外,作为一个程序员,即使我们了解了Linux管道的实现,我们的代码也不能依赖其特性,所以处理管道时该越界判断还是要判断,该错误检查还是要检查,这样代码才能更健壮。

FIFO

命名管道在底层的实现跟匿名管道完全一致,区别只是命名管道会有一个全局可见的文件名以供别人open打开使用。再程序中创建一个命名管道文件的方法有两种,一种是使用mkfifo函数。另一种是使用mknod系统调用,例子如下:

[zorro@zorro-pc pipe]$ cat mymkfifo.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>

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

    if (argc != 2) {
        fprintf(stderr, "Argument error!\n");
        exit(1);
    }

/*
    if (mkfifo(argv[1], 0600) < 0) {
        perror("mkfifo()");
        exit(1);
    }
*/
    if (mknod(argv[1], 0600|S_IFIFO, 0) < 0) {
        perror("mknod()");
        exit(1);
    }

    exit(0);
}

我们使用第一个参数作为创建的文件路径。创建完之后,其他进程就可以使用open()、read()、write()标准文件操作等方法进行使用了。其余所有的操作跟匿名管道使用类似。需要注意的是,无论命名还是匿名管道,它的文件描述都没有偏移量的概念,所以不能用lseek进行偏移量调整。

关于管道的其它议题,比如popen、pclose的使用等话题,《UNIX环境高级编程》中的相关章节已经讲的很清楚了。如果想学习补充这些知识,请参见此书。

最后

希望这些内容对大家进一步深入了解管道有帮助。如果有相关问题,可以在我的微博、微信或者博客上联系我。

如果觉得本文还不错,请扫码任意打赏捐助穷佐罗。

收钱


大家好,我是Zorro!

如果你喜欢本文,欢迎在微博上搜索“orroz”关注我,地址是:http://weibo.com/orroz

大家也可以在微信上搜索:Linux系统技术 关注我的公众号。

我的所有文章都会沉淀在我的个人博客上,地址是:http://liwei.life。

欢迎使用以上各种方式一起探讨学习,共同进步。

公众号二维码:

Zorro] icon


 

find命令详解

find命令详解

版权声明:

本文章内容在非商业使用前提下可无需授权任意转载、发布。

转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:orroz

微信公众号:Linux系统技术

前言

find命令是我们日常工作中比较常用的Linux命令。全面的掌握这个命令可以使很多操作达到事半功倍的效果。如果对find命令有以下这些疑惑,本文都能帮你解决:

  1. find命令的格式是什么?
  2. 参数中出现+或-号是什么意思?比如find / -mtime +7与find / -mtime -7什么区别?
  3. find /etc/ -name “passwd” -exec echo {} \;和find /etc/ -name “passwd” -exec echo {} +有啥区别?
  4. -exec参数为什么要以“\;”结尾,而不是只写“;”?

捐助穷佐罗,让他过上温饱生活。

收钱

命令基础

find命令大家都比较熟悉,反倒想讲的有特色比较困难。那干脆我们怎么平淡怎么来好了。我们一般用的find命令格式很简单,一般分成三个部分:

find /etc -name "passwd"

格式如上,第一段find命令。第二段,要搜索的路径。这一段目录可以写多个,如:

find /etc /var /usr -name "passwd"

第三段,表达式。我们例子中用的是-name “passwd”这个表达式,指定条件为找到文件名是passwd的文件。对于find命令,最需要学习的是表达式这一段。表达式决定了我们要找的文件是什么属性的文件,还可以指定一些“动作”,比如将匹配某种条件的文件删除。所以,find命令的核心就是表达式(EXPRESSION)的指定方法。

find命令中的表达式有四种类型,分别是:

  1. Tests:就是我们最常用的指定查找文件的条件。
  2. Actions:对找到的文件可以做的操作。
  3. Global options:全局属性用来限制一些查找的条件,比如常见的目录层次深度的限制。
  4. Positional options:位置属性用来指定一些查找的位置条件。

这其中最重要的就是Tests和Actions,他们是find命令的核心。另外还有可以将多个表达式连接起来的操作符,他们可以表达多个表达式之间的逻辑关系和运算优先顺序,叫做Operators。

下面我们就来分类看一下这些个分类的功能。

TESTS

find命令是通过文件属性查找文件的。所以,find表达式的tests都是文件的属性条件,比如文件的各种时间,文件权限等。很多参数中会出现指定一个数字n,一般会出现三种写法:

+n:表示大于n。

-n:表示小于n。

n:表示等于n。

根据时间查找

比较常用数字方式来指定的参数是针对时间的查找,比如-mtime n:查找文件修改时间,单位是天,就是n*24小时。举个例子说:

[root@zorrozou-pc0 zorro]# find / -mtime 7 -ls

我们为了方便看到结果,在这个命令中使用了-ls参数,具体细节后面会详细解释。再此我们只需要知道这个参数可以将符合条件的文件的相关属性显示出来即可。那么我们就可以通过这个命令看到查找到的文件的修改时间了。

[root@zorrozou-pc0 zorro]# find / -mtime 7 -ls|head
   524295      4 drwxr-xr-x  12  root     root         4096 6月  8 13:43 /root/.config
   524423      4 drwxr-xr-x   2  root     root         4096 6月  8 13:43 /root/.config/yelp
   524299      4 drwxr-xr-x   2  root     root         4096 6月  8 13:23 /root/.config/dconf
   524427      4 -rw-r--r--   1  root     root         3040 6月  8 13:23 /root/.config/dconf/user
...

我们会发现,时间都集中在6月8号,而今天是:

[root@zorrozou-pc0 zorro]# date
2016年 06月 15日 星期三 14:30:09 CST

实际上,当我们在mtime后面指定的是7的时候,实际上是找到了距离现在7个24小时之前修改过的文件。如果我们在考究一下细节的话,可以使用这个命令再将找到的文件用时间排下顺序:

[root@zorrozou-pc0 zorro]# find / -mtime 7 -exec ls -tld {} \+

此命令用到了exec参数,后面会详细说明。我们会发现,找到的文件实际上是集中在6月7日的14:30到6月8日的14:30这个范围内的。就是说,实际上,指定7天的意思是说,找到文件修改时间范围属于距离当前时间7个24小时到8个24小时之间的文件,这是不加任何+-符号的7的含义。如果是-mtime -7呢?

[root@zorrozou-pc0 zorro]# find / -mtime -7 -exec ls -tld {} \+

你会发现找到的文件是从现在开始到7个24小时范围内的文件。但是不包括7个24小时到8个24小时的时间范围。那么-mtime +7也应该好理解了。这就是find指定时间的含义。类似的参数还有:

-ctime:以天为单位通过change time查找文件。

-atime:以天为单位通过access time查找文件。

-mmin:以分钟为单位通过modify time查找文件。

-amin:以分钟为单位通过access time查找文件。

-cmin:以分钟单位通过change time查找文件。

这些参数都是指定一个时间数字n,数字的意义跟mtime完全一样,只是时间的单位和查找的时间不一样。

除了指定时间以外,find还可以通过对比某一个文件的相关时间找到符合条件的文件,比如-anewer file。

[root@zorrozou-pc0 zorro]# find /etc -anewer /etc/passwd

这样可以在/etc/目录下找到文件的access time比/etc/passwd的access time更新的所有文件。类似的参数还有:

-cnewer:比较文件的change time。

-newer:比较文件的modify time。

-newer还有一种特殊用法,可以用来做各种时间之间的比较。比如,我想找到文件修改时间比/etc/passwd文件的change time更新的文件:

[root@zorrozou-pc0 zorro]# find /etc/ -newermc /etc/passwd

这个用法的原型是:find /etc/ -newerXY file。其中Y表示的是跟后面file的什么时间比较,而X表示使用查找文件什么时间进行比较。-newermc就是拿文件的modify time时间跟file的change time进行比较。X和Y可以使用的字母为:

a:文件access time。
c:文件change time。
m:文件modify time。

在某些支持记录文件的创建时间的文件系统上,可以使用B来表示文件创建时间。ext系列文件系统并不支持记录这个时间。

根据用户查找

-uid n:文件的所属用户uid为n。

-user name:文件的所属用户为name。

-gid n:文件的所属组gid为n。

-group name:所属组为name的文件。

-nogroup:没有所属组的文件。

-nouser:没有所属用户的文件。

根据权限查找

-executable:文件可执行。

-readable:文件可读。

-writable:文件可写。

-perm mode:查找权限为mode的文件,mode的写法可以是数字,也可以是ugo=rwx的方式如:

[root@zorrozou-pc0 zorro]# find /etc/ -perm 644 -ls

这个写法跟:

[root@zorrozou-pc0 zorro]# find /etc/ -perm u=rw,g=r,o=r -ls

是等效的。

另外要注意,mode指定的是完全符合这个权限的文件,如:

[root@zorrozou-pc0 zorro]# find /etc/ -perm u=rw,g=r -ls
   263562      4 -rw-r-----   1  root     brlapi         33 11月 13  2015 /etc/brlapi.key

没描述的权限就相当于指定了没有这个权限。

mode还可以使用/或-作为前缀进行描述。如果指定了-mode,就表示没指定的权限是忽略的,就是说,权限中只要包涵相关权限即可。如:

[root@zorrozou-pc0 zorro]# find /etc/ -perm 600 -ls

这是找到所有只有rw——-权限的文件,而-600就表示只要是包括了rw的其他位任意的文件。mode加/前缀表示的是,指定的权限只要某一位复合条件就可以,其他位跟-一样忽略,就是说-perm /600还可以找到r——–或者-w——-这样权限的文件。老版本的/前缀是用+表示的,新版本的find意境不支持mode前加+前缀了。

根据路径查找

-name pattern:文件名为pattern指定字符串的文件。注意如果pattern中包括*等特殊符号的时候,需要加””。

-iname:name的忽略大小写版本。

-lname pattern:查找符号连接文件名为pattern的文件。

-ilname:lname的忽略大小写版本。

-path pattern:根据完整路径查找文件名为pattern的文件,如:

[root@zorrozou-pc0 zorro]# find /etc -path "/e*d"| head
/etc/machine-id
/etc/profile.d
/etc/vnc/xstartup.old
/etc/vnc/config.d
/etc/vnc/updateid
/etc/.updated

-ipath:path的忽略大小写版本。

-regex pattern:用正则表达式匹配文件名。

-iregex:regex的忽略大小写版本。

其他状态查找

-empty:文件为空而且是一个普通文件或者目录。

-size n[cwbkMG]:指定文件长度查找文件。单位选择位:

c:字节单位。

b:块为单位,块大小为512字节,这个是默认单位。

w:以words为单位,words表示两个字节。

k:以1024字节为单位。

M:以1048576字节为单位。

G:以1073741824字节温单位。

n的数字指定也可以使用+-号作为前缀。意义跟时间类似,表示找到小于(-)指定长度的文件或者大于(+)指定长度的文件。

-inum:根据文件的inode编号查找。

-links n:根据文件连接数查找。

-samefile name:找到跟name指定的文件完全一样的文件,就是说两个文件是硬连接关系。

-type c:以文件类型查找文件:

c可以选择的类型为:

b:块设备

c:字符设备

d:目录

p:命名管道

f:普通文件

l:符号连接

s:socket

ACTIONS

表达式中的actions类型参数主要是用来对找到的文件进行操作的参数。在上面的例子中,我们已经看到可以使用-ls参数对找到的文件进行长格式显示,这就是一个actions类型的参数。类似的参数还有。

-fls file:跟-ls功能一样,区别是将信息写入file指定的文件,而不是显示在屏幕上。

-print:将找到的文件显示在屏幕上,实际上默认find命令就会将文件打印出来显示。

-print0:-print参数会将每个文件用换行分割,而这个参数适用null分割。有时候在脚本编程时可能会用上。

-fprint file:-print参数的写入文件版本。将内容写到文件中,而不是显示在屏幕上。

-fprint0 file:-print0的写入文件版本。

-delete:可以将找到的文件直接删除。

-printf:格式化输出方式打印。如:

[root@zorrozou-pc0 zorro]# find /etc/ -name "pass*" -printf "%p "
/etc/default/passwd /etc/pam.d/passwd /etc/passwd- /etc/passwd 

显示文件名,并以空格分隔。%p代表文件名。其他信息可以参见man find。

-prune:如果复合条件的是一个目录,则不进入目录进行查找。例子:

[root@zorrozou-pc0 zorro]# mkdir /etc/passs
[root@zorrozou-pc0 zorro]# touch /etc/passs/passwd
[root@zorrozou-pc0 zorro]# find /etc/ -name "pass*" -prune
/etc/passs
/etc/default/passwd
/etc/pam.d/passwd
/etc/passwd-
/etc/passwd
[root@zorrozou-pc0 zorro]# find /etc/ -name "pass*"
/etc/passs
/etc/passs/passwd
/etc/default/passwd
/etc/pam.d/passwd
/etc/passwd-
/etc/passwd

我们先创建了一个/etc/passs的目录,然后在这个目录下创建了一个叫passwd的文件。之后先用带-prune的find看到,能显示出passs目录,但是目录中的passwd文件并没有显示,说明这个参数让find命令没有进入这个目录查找。而后一个不带-prune参数的find显示出了passs目录下的passwd。

-quit:找到符合条件的文件后立即退出。


捐助穷佐罗,让他过上温饱生活。

收钱


find中执行命令

-exec

find命令的exec是一个非常好用的参数,当然其可能造成的破坏也可能非常大。在学习它之前,我先要提醒大家,使用之前千万要确定自己在做什么。

这个参数的常见格式是:

-exec command ;

注意后面的分号。它是用来给find做标记用的。find在解析命令的时候,要区分给定的参数是要传给自己的还是要传给command命令的。所以find以分号作为要执行命令所有参数的结束标记。命令返回值为0则返回true。在exec参数指定的执行命令中,可以使用{}符号表示当前find找到的文件名。比如:

[root@zorrozou-pc0 find]# find /etc/ -name "passwd" -exec echo {} \;
/etc/default/passwd
/etc/pam.d/passwd
/etc/passwd

上面的命令表示,找到/etc/目录下文件名为passwd的文件,并echo其文件名。注意再使用分号的时候前面要加转移字符\,因为分号也是bash的特殊字符,所以bash会先解释它。前面加上\就可以让bash直接将其船体给find命令,这个分号由find解释,而不是bash。其实这个exec用的比较废话,毕竟find本身就会找到相关条件的文件并显示其文件名。但是试想如果我们将echo换成rm或者cp,是不是就有意义的多?比如:

[root@zorrozou-pc0 find]# find /etc/ -name "passwd" -exec rm {} \;

请不要执行这个命令!!

或者:

[root@zorrozou-pc0 find]# find /etc/ -name "passwd" -exec cp {} {}.bak \;

这个命令可以将符合条件的文件都加个.bak后缀备份一份。于是我们可以执行删除了:

[root@zorrozou-pc0 find]# find /etc/ -name "passwd.bak" 
/etc/default/passwd.bak
/etc/pam.d/passwd.bak
/etc/passwd.bak
[root@zorrozou-pc0 find]# find /etc/ -name "passwd.bak" -exec rm {} \;
[root@zorrozou-pc0 find]# find /etc/ -name "passwd.bak" 

当然,删除前还是要确认清楚你要删的文件一定是对的。

-execdir

execdir和exec有一些差别,主要是在执行指定的命令时,那个相关命令是在那个工作目录下执行的差别。exec是在find所指定的起始目录,而execdir是文件所在目录。对比一下就明白了:

[root@zorrozou-pc0 find]# find /etc/ -name "passwd" -exec echo {} \;
/etc/default/passwd
/etc/pam.d/passwd
/etc/passwd
[root@zorrozou-pc0 find]# find /etc/ -name "passwd" -execdir echo {} \;
./passwd
./passwd
./passwd

一个命令打印出来的路径都是/etc/开头,另一个显示的都是当前目录下的某某文件。

execdir的方式要比exec安全一些,因为这种执行方式避免了在解析文件名时所产生的竞争条件。

出了上述两种比较典型的执行命令的方法以外,find还对这两个参数提供了另一种形式的命令执行格式:

-exec command {} +

-execdir command {} +

我们还是先用例子来看一下这个格式和以分号结束的方式的差别:

[root@zorrozou-pc0 find]# find /etc/ -name "passwd" -exec echo {} \;
/etc/default/passwd
/etc/pam.d/passwd
/etc/passwd
[root@zorrozou-pc0 find]# find /etc/ -name "passwd" -exec echo {} \+
/etc/default/passwd /etc/pam.d/passwd /etc/passwd

光这样看可能还不是很明显,我们可以这样在描述一遍他们的执行过程:

echo /etc/default/passwd
echo /etc/pam.d/passwd
echo /etc/passwd

echo /etc/default/passwd /etc/pam.d/passwd /etc/passwd

其实就是说,对于command {} ;格式来说,每找到一个文件就执行一遍相关命令,而command {} +格式的意思是说,先执行find,找到所有符合条件的文件之后,将每个文件作为命令的一个参数传给命令执行,exec指定的命令实际上只被执行了一次。这样用的限制也是不言而喻的:{}只能出现一次。

[root@zorrozou-pc0 find]# find /etc -mtime -7 -type f -exec cp -t /tmp/back/ {} \+

上面这个命令将符合条件的文件全部cp到了/tmp/back目录中,当然如果文件有重名的情况下,会被覆盖掉。从这个命令中我们学习一下{} +格式的使用注意事项,它不能写成:

find /etc -mtime -7 -type f -exec cp {} /tmp/back/ \+

所以只能使用-t参数改变cp命令的参数顺序来指定相关的动作。

无论如何,直接使用exec和execdir是很危险的,因为他们会直接对找到的文件调用相关命令,并且没有任何确认。所以我们不得不在进行相关操作前再三确认,以防止误操作。当然,find命令也给了更安全的exec参数,它们就是:

-ok

-okdir

它们的作用跟exec和execdir一样,区别只是在做任何操作之前,会让用户确认是不是ok?如:

[root@zorrozou-pc0 find]# find /etc -mtime -7 -type f -ok cp -t /tmp/back/ {} \;
< cp ... /etc/bluetooth/main.conf > ? 

于是,每一次cp你都要确认是不是要这么做。只要你输入的是y或者以y开头的任何字符串,都是确认。其他的字符串是否认。另外,这两个参数不支持{} +的格式。

OPERATORS

find的操作符(OPERATORS)实际上是用来连接多个表达式和确定其逻辑关系用的。如:

[root@zorrozou-pc0 zorro]# find /etc -name "pass*" -type f
/etc/passs/passwd
/etc/default/passwd
/etc/pam.d/passwd
/etc/passwd-
/etc/passwd

这个find命令中使用了两个表达式,他们之间没有任何分隔,这是实际上表达的含义是,找到两个条件都符合的文件。实际上就是表达式的逻辑与关系,这跟-a参数连接或者-and参数一样:

[root@zorrozou-pc0 zorro]# find /etc -name "pass*" -a -type f
/etc/passs/passwd
/etc/default/passwd
/etc/pam.d/passwd
/etc/passwd-
/etc/passwd
[root@zorrozou-pc0 zorro]# find /etc -name "pass*" -and -type f
/etc/passs/passwd
/etc/default/passwd
/etc/pam.d/passwd
/etc/passwd-
/etc/passwd

除了逻辑与关系以外,还有逻辑或关系:

[root@zorrozou-pc0 zorro]# find /etc -name "pass*" -o -type f

[root@zorrozou-pc0 zorro]# find /etc -name "pass*" -or -type f

表示两个条件只要符合其中一个都可以。

在条件表达式前面加!表示对表达式取非。同样的也可以用-not参数。另外如果表达式很多,可以使用( expr )确定优先级,如:

[root@zorrozou-pc0 zorro]# find / \( -name "passwd" -a -type f \) -o \( -name "shadow" -a -type f \)

这里表示的是:-name “passwd” -a -type f和-name “shadow” -a -type f是或关系。

最后

find中还可能常用的其他参数比如:

-depth:制定了这个参数后,遇到目录先进入目录操作目录中的文件,最后再操作目录本身。

-maxdepth:目录最大深度限制。

-mindepth:目录最小深度限制。

还有一些其他相关参数大家可以在man find中自行补充,就不在这更多废话了。希望本篇可以对大家深入的掌握find命令有所帮助。

如果有相关问题,可以在我的微博、微信或者博客上联系我。

捐助穷佐罗,让他过上温饱生活。

收钱


大家好,我是Zorro!

如果你喜欢本文,欢迎在微博上搜索“orroz”关注我,地址是:http://weibo.com/orroz

大家也可以在微信上搜索:Linux系统技术 关注我的公众号。

我的所有文章都会沉淀在我的个人博客上,地址是:http://liwei.life。

欢迎使用以上各种方式一起探讨学习,共同进步。

公众号二维码:

Zorro] icon


 

sed命令进阶

sed命令进阶

版权声明:

本文章内容在非商业使用前提下可无需授权任意转载、发布。

转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:**orroz**

微信公众号:**Linux系统技术**

前言

本文主要介绍sed的高级用法,在阅读本文之前希望读者已经掌握sed的基本使用和正则表达式的相关知识。本文主要可以让读者学会如何使用sed处理段落内容

希望本文能对你帮助。

捐助穷佐罗,让他过上温饱生活。

收钱

问题举例

日常工作中我们都经常会使用sed命令对文件进行处理。最普遍的是以行为单位处理,比如做替换操作,如:

[root@TENCENT64 ~]# head -3 /etc/passwd 
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
[root@TENCENT64 ~]# head -3 /etc/passwd |sed 's/bin/xxx/g'
root:x:0:0:root:/root:/xxx/bash
xxx:x:1:1:xxx:/xxx:/sxxx/nologin
daemon:x:2:2:daemon:/sxxx:/sxxx/nologin

于是每一行中只要有”bin”这个关键字的就都被替换成了”xxx”。以”行”为单位的操作,了解sed基本知识之后应该都能处理。我们下面通过一个对段落的操作,来进一步学习一下sed命令的高级用法。

工作场景下有时候遇到的文本内容并不仅仅是以行为单位的输出。举个例子来说,比如ifconfig命令的输出:

[root@TENCENT64 ~]# ifconfig
eth1 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
inet addr:10.0.0.1 Bcast:10.213.123.255 Mask:255.255.252.0
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:4699092093 errors:0 dropped:0 overruns:0 frame:0
TX packets:4429422167 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0 
RX bytes:621383727756 (578.7 GiB) TX bytes:987104190070 (919.3 GiB)

eth2 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:421719237 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0 
RX bytes:17982386416 (16.7 GiB) TX bytes:0 (0.0 b)

eth3 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:401384950 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0 
RX bytes:16934321897 (15.7 GiB) TX bytes:0 (0.0 b)

eth4 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:139540553 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0 
RX bytes:5929444543 (5.5 GiB) TX bytes:0 (0.0 b)

eth5 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:1810276711441 errors:0 dropped:0 overruns:22712568 frame:0
TX packets:1951148522633 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000 
RX bytes:743435149333809 (676.1 TiB) TX bytes:707431806048867 (643.4 TiB)
Memory:a9a40000-a9a60000

eth6 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
UP BROADCAST RUNNING PROMISC MULTICAST MTU:1500 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:210438688172 errors:0 dropped:9029561 overruns:0 carrier:0
collisions:0 txqueuelen:0 
RX bytes:0 (0.0 b) TX bytes:219869363023063 (199.9 TiB)

eth7 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
UP BROADCAST RUNNING PROMISC MULTICAST MTU:1500 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:628704252152 errors:0 dropped:20495142 overruns:0 carrier:0
collisions:0 txqueuelen:0 
RX bytes:0 (0.0 b) TX bytes:247252814884293 (224.8 TiB)

eth8 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
UP BROADCAST RUNNING PROMISC MULTICAST MTU:1500 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:7987321426 errors:0 dropped:904533 overruns:0 carrier:0
collisions:0 txqueuelen:0 
RX bytes:0 (0.0 b) TX bytes:1467641590110 (1.3 TiB)

eth9 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
UP BROADCAST RUNNING PROMISC MULTICAST MTU:1500 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:1088671818709 errors:0 dropped:13914796 overruns:0 carrier:0
collisions:0 txqueuelen:0 
RX bytes:0 (0.0 b) TX bytes:236639340770145 (215.2 TiB)

lo Link encap:Local Loopback 
inet addr:127.0.0.1 Mask:255.0.0.0
UP LOOPBACK RUNNING MTU:16436 Metric:1
RX packets:3094508 errors:0 dropped:0 overruns:0 frame:0
TX packets:3094508 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0 
RX bytes:1579253954 (1.4 GiB) TX bytes:1579253954 (1.4 GiB)

br0 Link encap:Ethernet HWaddr FE:BD:B8:D5:79:46 
UP BROADCAST RUNNING PROMISC MULTICAST MTU:1500 Metric:1
RX packets:118073976200 errors:0 dropped:0 overruns:0 frame:0
TX packets:129141892891 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000 
RX bytes:13271406153198 (12.0 TiB) TX bytes:21428348510630 (19.4 TiB)

br1 Link encap:Ethernet HWaddr FE:BD:B8:D5:87:36 
UP BROADCAST RUNNING PROMISC MULTICAST MTU:1500 Metric:1
RX packets:210447731529 errors:0 dropped:0 overruns:0 frame:0
TX packets:145867293712 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000 
RX bytes:216934635012821 (197.3 TiB) TX bytes:112307933521307 (102.1 TiB)

br2 Link encap:Ethernet HWaddr FE:BD:B8:D5:A1:1C 
UP BROADCAST RUNNING PROMISC MULTICAST MTU:1500 Metric:1
RX packets:227580515069 errors:0 dropped:0 overruns:0 frame:0
TX packets:224128670696 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000 
RX bytes:146402818737176 (133.1 TiB) TX bytes:121031384149060 (110.0 TiB)

br3 Link encap:Ethernet HWaddr FE:BD:B8:D5:A1:F4 
UP BROADCAST RUNNING PROMISC MULTICAST MTU:1500 Metric:1
RX packets:210447731529 errors:0 dropped:0 overruns:0 frame:0
TX packets:145867293713 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000 
RX bytes:216934635012821 (197.3 TiB) TX bytes:112307933522807 (102.1 TiB)

br4 Link encap:Ethernet HWaddr FE:BD:B8:D5:A4:9A 
UP BROADCAST RUNNING PROMISC MULTICAST MTU:1500 Metric:1
RX packets:210447731531 errors:0 dropped:0 overruns:0 frame:0
TX packets:145867293714 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000 
RX bytes:216934635013645 (197.3 TiB) TX bytes:112307933522867 (102.1 TiB)

br5 Link encap:Ethernet HWaddr FE:BD:B8:D5:AA:21 
UP BROADCAST RUNNING PROMISC MULTICAST MTU:1500 Metric:1
RX packets:7988225959 errors:0 dropped:0 overruns:0 frame:0
TX packets:9460529821 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000 
RX bytes:1355936046078 (1.2 TiB) TX bytes:1354671618850 (1.2 TiB)

这是一个有很多网卡的服务器。观察以上输出我们会发现,每个网卡的信息都有多行内容,组成一段。网卡名称和MAC地址在一行,网卡名称IP地址不在一行。类似的还有网卡的收发包数量的信息,以及收发包的字节数。那么对于类似这样的文本内容,当我们想要使用sed将输出处理成:网卡名对应IP地址或者网卡名对应收包字节数,将不在同一行的信息放到同一行再输出该怎么处理呢?类似这样:

网卡名:收包字节数
eth1:621383727756
eth2:17982386416
...

这样的需求对于一般的sed命令来说显然做不到了。我们需要引入更高级的sed处理功能来处理类似问题。大家可以先将上述代码保存到一个文本文件里以备后续实验,我们先给出答案:

网卡名对应RX字节数:

[root@zorrozou-pc0 zorro]# sed -n '/^[^ ]/{s/^\([^ ]*\) .*/\1/g;h;: top;n;/^$/b;s/^.*RX bytes:\([0-9]\{1,\}\).*/\1/g;T top;H;x;s/\n/:/g;p}' ifconfig.out 
eth1:621383727756
eth2:17982386416
eth3:16934321897
eth4:5929444543
eth5:743435149333809
eth6:0
eth7:0
eth8:0
eth9:0
lo:1579253954
br0:13271406153198
br1:216934635012821
br2:146402818737176
br3:216934635012821
br4:216934635013645
br5:1355936046078

网卡名对应ip地址:

[root@zorrozou-pc0 zorro]# sed -n '/^[^ ]/{s/^\([^ ]*\) .*/\1/g;h;: top;n;/^$/b;s/^.*inet addr:\([0-9]\{1,\}\.[0-9]\{1,\}\.[0-9]\{1,\}\.[0-9]\{1,\}\).*/\1/g;T top;H;x;s/\n/:/g;p}' ifconfig.out 
eth1:10.0.0.1
lo:127.0.0.1

我们还会发现显示IP的sed命令很智能的过滤掉了没有IP地址的网卡,只把有IP的网卡显示出来。相信一部分人看到这个命令已经晕了。先不要着急,讲解这个命令的含义之前,我们需要先来了解一下sed的工作原理。

模式空间和保存空间

默认情况下,sed是将输入内容一行一行进行处理的。如果我们拿一个简单的sed命令举例,如:sed ‘1,$p’ /etc/passwd。处理过程中,sed会一行一行的读入/etc/passwd文件,查看每行是否匹配定址条件(1,$),如果匹配条件,就讲行内容放入模式空间,并打印(p命令)。由于文本流本身的输出,而模式空间内容又被打印一遍,所以这个命令最后会将每一行都显示2遍。

[zorro@zorrozou-pc0 ~]$ sed '1,$p' /etc/passwd
root:x:0:0:root:/root:/bin/bash
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/usr/bin/nologin
bin:x:1:1:bin:/bin:/usr/bin/nologin
daemon:x:2:2:daemon:/:/usr/bin/nologin
daemon:x:2:2:daemon:/:/usr/bin/nologin

默认情况下,sed程序在所有的脚本指令执行完毕后,将自动打印模式空间中的内容,这会导致p命令输出相关行两遍,-n选项可以屏蔽自动打印。

模式空间(pattern space)

模式空间的英文是pattern space。在sed的处理过程中使用这个缓存空间缓存了要处理的内容。由上面的例子可以看到,模式空间是sed处理最核心的缓存空间,所有要处理的行内容都会复制进这个空间再进行修改,或根据需要显示。默认情况下sed也并不会修改原文件本身内容,只修改模式空间内容。

保存空间(hlod space)

除了模式空间以外,sed还实现了另一个内容缓存空间,名字叫保存空间。大家可以想象这个空间跟模式空间一样,也就是一段内存空间,一般情况下不用,除非我们使用相关指令才会对它进行使用。

示例命令分析

了解了以上两个空间以后,我们来看看例子中的命令到底进行了什么处理,例子原来是这样的:

sed -n '/^[^ ]/{s/^\([^ ]*\) .*/\1/g;h;: top;n;/^$/b;s/^.*RX bytes:\([0-9]\{1,\}\).*/\1/g;T top;H;x;s/\n/:/g;p}' ifconfig.out

为了方便分析,我们将这个sed命令写成一个sed脚本,用更清晰的语法结构再来看一下。脚本跟远命令有少许差别,最后的p打印变成了写文件:

[zorro@zorrozou-pc0 ~]$ cat -n ifconfig.sed 
1 #!/usr/bin/sed -f
2 
3 /^[^ ]/{
4 s/^\([^ ]*\) .*/\1/g;
5 h;
6 : top;
7 n;
8 /^$/b;
9 s/^.*RX bytes:\([0-9]\{1,\}\).*/\1/g;
10 T top;
11 H;
12 x;
13 s/\n/:/g;
14 # p;
15 w result
16 }

这个sed脚本执行结果是这样的:

[zorro@zorrozou-pc0 ~]$ ./ifconfig.sed ifconfig.out 
eth1
inet addr:10.0.0.1 Bcast:10.213.123.255 Mask:255.255.252.0
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:4699092093 errors:0 dropped:0 overruns:0 frame:0
TX packets:4429422167 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0 
eth1:621383727756

eth2
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
......

最后所有有效输出会写入result文件:

[zorro@zorrozou-pc0 ~]$ cat result 
eth1:621383727756
eth2:17982386416
eth3:16934321897
eth4:5929444543
eth5:743435149333809
eth6:0
eth7:0
eth8:0
eth9:0
lo:1579253954
br0:13271406153198
br1:216934635012821
br2:146402818737176
br3:216934635012821
br4:216934635013645
br5:1355936046078

在分析脚本代码之前,我们先要理清楚根据输出内容要做的处理思路。观察整个ifconfig命令输出的内容,我们会发现,每个网卡输出的信息都是由多行组成的,并且每段之间由空行分隔。每个网卡信息段的输出第一行都是网卡名,并且网卡名称没有任何前缀字符:

sed

 

从输出内容分析,我们应该做的事情是,找到以非空格开头的行,取出网卡名记录下来,并且再从下面的非空行找RX bytes:这个关键字,取出它后面的数字(接收字节数)。如果碰见空行,则表示这段分析完成,开始下一段分析。开始分析我们的程序,我们先从头来,如何取出网卡名所在的行?这个很简单,只要行开始不是以空格开头,就是网卡名所在行,所以:

[zorro@zorrozou-pc0 ~]$ sed -n '/^[^ ]/p' ifconfig.out 
eth1 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
eth2 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
eth3 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
eth4 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
eth5 Link encap:Ethernet HWaddr 40:F2:E9:09:FC:45 
......

非空格开头的正则表达式,锚定了段操作的开头。当然,我们找到段操作开头之后,后面绝不仅仅是一个简单的p打印,我们需要更复杂的组合操作。此时{}语法就起到作用了。sed的{}可以将多个命令组合进行操作,每个命令使用分号”;”分隔。如:

[zorro@zorrozou-pc0 ~]$ sed -n '/^[^ ]/{s/^\([^ ]*\) .*/\1/g;p}' ifconfig.out 
eth1
eth2
eth3
eth4
eth5

这个命令的意思就是,取出网卡名所在的行,现在模式空间中进行替换操作,只保留网卡名(s/^([^ ]) ./\1/g),然后打印模式空间内容p。这里涉及到一个sed替换命令的正则表达式保存功能,如果不清楚的请自行补充相关知识。

学会了组合命令的{}方法之后,我们就该考虑找到了网卡名所在行之后该做什么了。第一个该做的事情就是,保存网卡名,并且只保留网卡名,别的信息不要。刚才已经通过替换命令实现了。然后下一步应该读取下一行进入模式空间,然后看看下一行中有没有RX bytes:,如果有,就取后面的数组进行保存。如果没有就再看下一行,如此循环,直到看到空行为止。这个过程中,我们需要对找到的有用信息进行保存,如:网卡名,接收数据包。那么保存到哪里呢?如果保存到模式空间,那么下一行读入的时候保存的信息就没了。所以此时我们就需要保存空间来帮我们保存内容了。后面的命令整个组成了一个逻辑,所以我们一起拿出来看:

[zorro@zorrozou-pc0 ~]$ cat -n ifconfig.sed 
1 #!/usr/bin/sed -f
2 
3 /^[^ ]/{
4 s/^\([^ ]*\) .*/\1/g;
5 h;
6 : top;
7 n;
8 /^$/b;
9 s/^.*RX bytes:\([0-9]\{1,\}\).*/\1/g;
10 T top;
11 H;
12 x;
13 s/\n/:/g;
14 # p;
15 w result
16 }

第3行和第3行不用解释了,找到网卡名的行并且在模式空间里只保留网卡名称。之后是第5行的h命令。h命令的意思是,将现在模式空间中的内容,放到保存空间。于是,网卡名就放进了保存空间。

然后是第6行,: top在这里只起到一个标记的作用。我们使用:标记了一个位置名叫top。我们先暂时记住它。

第7行n命令。这个命令就是读取下一行到模式空间。就是说,网卡行已经处理完,并且保存好了,可以n读入下一行了。n之后,下一行的内容就进入模式空间,然后继续从n下面的命令开始处理,就是第8行。

第8行使用的是b命令,用来跳出本次sed处理。这里的含义是检查下一行内容如果是空行/^$/,就跳出本段的sed处理,说明这段网卡信息分析完毕,可以进行下一段分析了。如果不是空行,这行就不起作用,于是继续处理第9行。b命令除了可以跳出本次处理以外,还可以指定跳转的位置,比如上文中使用:标记的top。

第9行使用s替换命令取出行中RX bytes:字符之后的所有数字。这里本身并没有分支判断,分支判断出现在下一行。

第10行T命令是一个逻辑判断指令。它的含义是,如果前面出现的s替换指令没有找到符合条件的行,则跳转到T后面所指定的标记位置。在这里的标记位置为top,是我们在上面使用:标记出来的位置。就是说,如果上面的s替换找不到RX bytes:关键字的行,那么就执行过程会跳回top位置继续执行,就是接着再看下一行进行检查。

在这里,我们实际上是用了冒号标签和T指令构成了一个循环。当条件是s替换找不到指定行的时候,就继续执行本循环。直到找到为止。找到之后就不会再跳回top位置了,而是继续执行第11行。

第11行的指令是H,意思是将当前模式空间中的内容追加进保存空间。当前模式空间已经由s替换指令处理成只保留了接受的字节数。之前的保存空间中已经存了网卡名,追加进字节数之后,保存空间里的内容将由两行构成,第一行是网卡名,第二行是接收字节数。注意h和H指令的区别,h是将当前模式空间中的内容保存进保存空间,这样做会使保存空间中的原有内容丢失。而H是将模式空间内容追加进保存空间,保存空间中的原来内容还在。

此时保存空间中已经存有我们所有想要的内容了,网卡名和接收字节数。它们是放在两行存的,这仍然不是我们想要的结果,我们希望能够在一行中显示,并用冒号分隔。所以下面要做的是替换,将换行替换成冒号。但是我们不能直接操作保存空间中的内容,所以需要将保存空间的内容拿回模式空间才能操作。

第12行是用了x指令,意思是将模式空间内容和保存空间内容互换。互换之后,网卡和接收字节数就回到模式空间了。然后我们可以使用s指令对模式空间内容做替换。

第13行s/\n/:/g,将换行替换成冒号。之后模式空间中的内容就是我们真正想要的“网卡名:接收字节数”了。于是就可以p打印或者使用w指令,保存模式空间内容到某个文件中。

所有指令的退出条件有两个:

  1. 第8行,遇到空格之后本段解析结束。
  2. 所有指令执行完,打印或保存了相关信息之后执行完。

执行完退出后,sed会接着找下一个网卡信息开始的段,继续下一个网卡的解析操作。这就是这个复杂sed命令的整体处理过程。

明白了整个操作过程之后,我们对sed的模式空间,保存空间和语法相关指令就应该建立了相关概念了。接下来我们还有必要学习一下sed还支持哪些指令,以便以后处理复杂的文本时可以使用。

常用高级操作命令

d:删除模式空间内容并开始下一个输入处理。

D:删除模式空间中的第一行内容,并开始下一个输入的处理。如果模式空间中只有一行内容,那它的作用跟d指令一样。

h:将模式空间拷贝到保存空间。

H:将模式空间追加到保存空间。

g:将保存空间复制到模式空间。

G:将保存空间追加到模式空间。

n:读入下一行到模式空间。

N:追加下一行到模式空间。

p:打印模式空间中的内容。

s///:替换模式空间中的内容。

x:将模式空间内容和保存空间内容交换。
lable:标记一个标签,注意lable的字符个数限制为7个字符以内。

t:测试这个指令之前的替换s///命令,如果替换命令能匹配到可替换的内容,则跳转到t指令后面标记的lable上。

T:测试这个指令之前的替换s///命令,如果替换命令不能匹配到可替换的内容,则跳转到T指令后面标记的lable上。

[zorro@zorrozou-pc0 ~]$ ip ad sh|sed -n '/^[0-9]/{:top;n;s/inet6/inet/;T top;p}'
inet ::1/128 scope host 
inet fe80::95a9:3e28:5102:1a84/64 scope link 
[zorro@zorrozou-pc0 ~]$ ip ad sh|sed -n '/^[0-9]/{:top;n;s/inet6/&/;T top;p}'
inet6 ::1/128 scope host 
inet6 fe80::95a9:3e28:5102:1a84/64 scope link 

定义一个标签top,如果s///的替换功能不能执行,则表示这行中没有inet6这个单词,于是就会到top读取下一行(n命令功能)。如果测试s///成功执行,则p打印相关行。这样就拿出来了有ipv6地址的行。

最后

关于sed的其它指令,基本都不是难点,这里不再解释。大家可以通过man sed查看帮助。

本文试图通过分析一个sed处理段落内容的案例,介绍了sed的模式空间、保存空间以及跳转、标签(label)这些概念和高级指令。希望大家能理解其使用方法和编程思路,以便在工作中能使用sed更加方便灵活的处理文本信息。工欲善其事,必先利其器。

谢谢大家!

如果本文对你有用,请任意打赏,穷佐罗再次拜谢了。

收钱


大家好,我是Zorro!

如果你喜欢本文,欢迎在微博上搜索“orroz”关注我,地址是:http://weibo.com/orroz

大家也可以在微信上搜索:Linux系统技术 关注我的公众号。

我的所有文章都会沉淀在我的个人博客上,地址是:http://liwei.life。

欢迎使用以上各种方式一起探讨学习,共同进步。

公众号二维码:

Zorro] icon


 

Linux的内存回收和交换

 

Linux的内存回收和交换

版权声明:

本文章内容在非商业使用前提下可无需授权任意转载、发布。

转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:orroz

微信公众号:Linux系统技术

前言

Linux的swap相关部分代码从2.6早期版本到现在的4.6版本在细节之处已经有不少变化。本文讨论的swap基于Linux 4.4内核代码。Linux内存管理是一套非常复杂的系统,而swap只是其中一个很小的处理逻辑。希望本文能让读者了解Linux对swap的使用大概是什么样子。阅读完本文,应该可以帮你解决以下问题:

  1. swap到底是干嘛的?
  2. swappiness到底是用来调节什么的?
  3. 什么是内存水位标记?
  4. kswapd什么时候会进行swap操作?
  5. swap分区的优先级(priority)有啥用?

什么是SWAP?

我们一般所说的swap,指的是一个交换分区或文件。在Linux上可以使用swapon -s命令查看当前系统上正在使用的交换空间有哪些,以及相关信息:

[zorro@zorrozou-pc0 linux-4.4]$ swapon -s
Filename Type Size Used Priority
/dev/dm-4 partition 33554428 0 -1

从功能上讲,交换分区主要是在内存不够用的时候,将部分内存上的数据交换到swap空间上,以便让系统不会因内存不够用而导致oom或者更致命的情况出现。所以,当内存使用存在压力,开始触发内存回收的行为时,就可能会使用swap空间。内核对swap的使用实际上是跟内存回收行为紧密结合的。那么内存回收和swap的关系,我们可以提出以下几个问题:

  1. 什么时候会进行内存回收呢?
  2. 哪些内存会可能被回收呢?
  3. 回收的过程中什么时候会进行交换呢?
  4. 具体怎么交换?

下面我们就从这些问题出发,一个一个进行分析。

扫一次只要9毛9,您要不要多扫几次呢?:P

mm_facetoface_collect_qrcode_1465221734716

内存回收

内核之所以要进行内存回收,主要原因有两个:

第一、内核需要为任何时刻突发到来的内存申请提供足够的内存。所以一般情况下保证有足够的free空间对于内核来说是必要的。另外,Linux内核使用cache的策略虽然是不用白不用,内核会使用内存中的page cache对部分文件进行缓存,以便提升文件的读写效率。所以内核有必要设计一个周期性回收内存的机制,以便cache的使用和其他相关内存的使用不至于让系统的剩余内存长期处于很少的状态。

第二,当真的有大于空闲内存的申请到来的时候,会触发强制内存回收。

所以,内核在应对这两类回收的需求下,分别实现了两种不同的机制。一个是使用kswapd进程对内存进行周期检查,以保证平常状态下剩余内存尽可能够用。另一个是直接内存回收(direct page reclaim),就是当内存分配时没有空闲内存可以满足要求时,触发直接内存回收。

这两种内存回收的触发路径不同,一个是由内核进程kswapd直接调用内存回收的逻辑进行内存回收(参见mm/vmscan.c中的kswapd()主逻辑),另一个是内存申请的时候进入slow path的内存申请逻辑进行回收(参见内核代码中的mm/page_alloc.c中的__alloc_pages_slowpath方法)。这两个方法中实际进行内存回收的过程殊途同归,最终都是调用shrink_zone()方法进行针对每个zone的内存页缩减。这个方法中会再调用shrink_lruvec()这个方法对每个组织页的链表进程检查。找到这个线索之后,我们就可以清晰的看到内存回收操作究竟针对的page有哪些了。这些链表主要定义在mm/vmscan.c一个enum中:

#define LRU_BASE 0
#define LRU_ACTIVE 1
#define LRU_FILE 2

enum lru_list {
LRU_INACTIVE_ANON = LRU_BASE,
LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE,
LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,
LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE,
LRU_UNEVICTABLE,
NR_LRU_LISTS
};

根据这个enum可以看到,内存回收主要需要进行扫描的包括anon的inactive和active以及file的inactive和active四个链表。就是说,内存回收操作主要针对的就是内存中的文件页(file cache)和匿名页。关于活跃(active)还是不活跃(inactive)的判断内核会使用lru算法进行处理并进行标记,我们这里不详细解释这个过程。

整个扫描的过程分几个循环,首先扫描每个zone上的cgroup组。然后再以cgroup的内存为单元进行page链表的扫描。内核会先扫描anon的active链表,将不频繁的放进inactive链表中,然后扫描inactive链表,将里面活跃的移回active中。进行swap的时候,先对inactive的页进行换出。如果是file的文件映射page页,则判断其是否为脏数据,如果是脏数据就写回,不是脏数据可以直接释放。

这样看来,内存回收这个行为会对两种内存的使用进行回收,一种是anon的匿名页内存,主要回收手段是swap,另一种是file-backed的文件映射页,主要的释放手段是写回和清空。因为针对file based的内存,没必要进行交换,其数据原本就在硬盘上,回收这部分内存只要在有脏数据时写回,并清空内存就可以了,以后有需要再从对应的文件读回来。内存对匿名页和文件缓存一共用了四条链表进行组织,回收过程主要是针对这四条链表进行扫描和操作。

swappiness的作用究竟是什么?

我们应该都知道/proc/sys/vm/swappiness这个文件,是个可以用来调整跟swap相关的参数。这个文件的默认值是60,可以的取值范围是0-100。这很容易给大家一个暗示:我是个百分比哦!那么这个文件具体到底代表什么意思呢?我们先来看一下说明:


swappiness

This control is used to define how aggressive the kernel will swap
memory pages. Higher values will increase agressiveness, lower values
decrease the amount of swap. A value of 0 instructs the kernel not to
initiate swap until the amount of free and file-backed pages is less
than the high water mark in a zone.

The default value is 60.


这个文件的值用来定义内核使用swap的积极程度。值越高,内核就会越积极的使用swap,值越低就会降低对swap的使用积极性。如果这个值为0,那么内存在free和file-backed使用的页面总量小于高水位标记(high water mark)之前,不会发生交换。

在这里我们可以理解file-backed这个词的含义了,实际上就是上文所说的文件映射页的大小。那么这个swappiness到底起到了什么作用呢?我们换个思路考虑这个事情。假设让我们设计一个内存回收机制,要去考虑将一部分内存写到swap分区上,将一部分file-backed的内存写回并清空,剩余部分内存出来,我们将怎么设计?

我想应该主要考虑这样几个问题。

  1. 如果回收内存可以有两种途径(匿名页交换和file缓存清空),那么我应该考虑在本次回收的时候,什么情况下多进行file写回,什么情况下应该多进行swap交换。说白了就是平衡两种回收手段的使用,以达到最优。
  2. 如果符合交换条件的内存较长,是不是可以不用全部交换出去?比如可以交换的内存有100M,但是目前只需要50M内存,实际只要交换50M就可以了,不用把能交换的都交换出去。

分析代码会发现,Linux内核对这部分逻辑的实现代码在get_scan_count()这个方法中,这个方法被shrink_lruvec()调用。get_sacn_count()就是处理上述逻辑的,swappiness是它所需要的一个参数,这个参数实际上是指导内核在清空内存的时候,是更倾向于清空file-backed内存还是更倾向于进行匿名页的交换的。当然,这只是个倾向性,是指在两个都够用的情况下,更愿意用哪个,如果不够用了,那么该交换还是要交换。

简单看一下get_sacn_count()函数的处理部分代码,其中关于swappiness的第一个处理是:

 /*
* With swappiness at 100, anonymous and file have the same priority.
* This scanning priority is essentially the inverse of IO cost.
*/
anon_prio = swappiness;
file_prio = 200 - anon_prio;

这里注释的很清楚,如果swappiness设置为100,那么匿名页和文件将用同样的优先级进行回收。很明显,使用清空文件的方式将有利于减轻内存回收时可能造成的IO压力。因为如果file-backed中的数据不是脏数据的话,那么可以不用写回,这样就没有IO发生,而一旦进行交换,就一定会造成IO。所以系统默认将swappiness的值设置为60,这样回收内存时,对file-backed的文件cache内存的清空比例会更大,内核将会更倾向于进行缓存清空而不是交换。

这里的swappiness值如果是60,那么是不是说内核回收的时候,会按照60:140的比例去做相应的swap和清空file-backed的空间呢?并不是。在做这个比例计算的时候,内核还要参考当前内存使用的其他信息。对这里具体是怎么处理感兴趣的人,可以自己详细看get_sacn_count()的实现,本文就不多解释了。我们在此要明确的概念是:swappiness的值是用来控制内存回收时,回收的匿名页更多一些还是回收的file cache更多一些

那么swappiness设置为0的话,是不是内核就根本不会进行swap了呢?这个答案也是否定的。首先是内存真的不够用的时候,该swap的话还是要swap。其次在内核中还有一个逻辑会导致直接使用swap,内核代码是这样处理的:

 /*
* Prevent the reclaimer from falling into the cache trap: as
* cache pages start out inactive, every cache fault will tip
* the scan balance towards the file LRU. And as the file LRU
* shrinks, so does the window for rotation from references.
* This means we have a runaway feedback loop where a tiny
* thrashing file LRU becomes infinitely more attractive than
* anon pages. Try to detect this based on file LRU size.
*/
if (global_reclaim(sc)) {
unsigned long zonefile;
unsigned long zonefree;

zonefree = zone_page_state(zone, NR_FREE_PAGES);
zonefile = zone_page_state(zone, NR_ACTIVE_FILE) +
zone_page_state(zone, NR_INACTIVE_FILE);

if (unlikely(zonefile + zonefree <= high_wmark_pages(zone))) {
scan_balance = SCAN_ANON;
goto out;
}
}

这里的逻辑是说,如果触发的是全局回收,并且zonefile + zonefree <= high_wmark_pages(zone)条件成立时,就将scan_balance这个标记置为SCAN_ANON。后续处理scan_balance的时候,如果它的值是SCAN_ANON,则一定会进行针对匿名页的swap操作。要理解这个行为,我们首先要搞清楚什么是高水位标记(high_wmark_pages)。

扫一次只要9毛9,欢迎您多扫几次!

mm_facetoface_collect_qrcode_1465221734716

内存水位标记(watermark)

我们回到kswapd周期检查和直接内存回收的两种内存回收机制。直接内存回收比较好理解,当申请的内存大于剩余内存的时候,就会触发直接回收。那么kswapd进程在周期检查的时候触发回收的条件是什么呢?还是从设计角度来看,kswapd进程要周期对内存进行检测,达到一定阈值的时候开始进行内存回收。这个所谓的阈值可以理解为内存目前的使用压力,就是说,虽然我们还有剩余内存,但是当剩余内存比较小的时候,就是内存压力较大的时候,就应该开始试图回收些内存了,这样才能保证系统尽可能的有足够的内存给突发的内存申请所使用。

那么如何描述内存使用的压力呢?Linux内核使用水位标记(watermark)的概念来描述这个压力情况。Linux为内存的使用设置了三种内存水位标记,high、low、min。他们所标记的分别含义为:剩余内存在high以上表示内存剩余较多,目前内存使用压力不大;high-low的范围表示目前剩余内存存在一定压力;low-min表示内存开始有较大使用压力,剩余内存不多了;min是最小的水位标记,当剩余内存达到这个状态时,就说明内存面临很大压力。小于min这部分内存,内核是保留给特定情况下使用的,一般不会分配。内存回收行为就是基于剩余内存的水位标记进行决策的,当系统剩余内存低于watermark[low]的时候,内核的kswapd开始起作用,进行内存回收。直到剩余内存达到watermark[high]的时候停止。如果内存消耗导致剩余内存达到了或超过了watermark[min]时,就会触发直接回收(direct reclaim)。

明白了水位标记的概念之后,zonefile + zonefree <= high_wmark_pages(zone)这个公式就能理解了。这里的zonefile相当于内存中文件映射的总量,zonefree相当于剩余内存的总量。内核一般认为,如果zonefile还有的话,就可以尽量通过清空文件缓存获得部分内存,而不必只使用swap方式对anon的内存进行交换。整个判断的概念是说,在全局回收的状态下(有global_reclaim(sc)标记),如果当前的文件映射内存总量+剩余内存总量的值评估小于等于watermark[high]标记的时候,就可以进行直接swap了。这样是为了防止进入cache陷阱,具体描述可以见代码注释。这个判断对系统的影响是,swappiness设置为0时,有剩余内存的情况下也可能发生交换。

那么watermark相关值是如何计算的呢?所有的内存watermark标记都是根据当前内存总大小和一个可调参数进行运算得来的,这个参数是:/proc/sys/vm/min_free_kbytes。首先这个参数本身决定了系统中每个zone的watermark[min]的值大小,然后内核根据min的大小并参考每个zone的内存大小分别算出每个zone的low水位和high水位值。
想了解具体逻辑可以参见源代码目录下的:mm/page_alloc.c文件。在系统中可以从/proc/zoneinfo文件中查看当前系统的相关的信息和使用情况。

我们会发现以上内存管理的相关逻辑都是以zone为单位的,这里zone的含义是指内存的分区管理。Linux将内存分成多个区,主要有直接访问区(DMA)、一般区(Normal)和高端内存区(HighMemory)。内核对内存不同区域的访问因为硬件结构因素会有寻址和效率上的差别。如果在NUMA架构上,不同CPU所管理的内存也是不同的zone。

相关参数设置

zone_reclaim_mode

zone_reclaim_mode模式是在2.6版本后期开始加入内核的一种模式,可以用来管理当一个内存区域(zone)内部的内存耗尽时,是从其内部进行内存回收还是可以从其他zone进行回收的选项,我们可以通过/proc/sys/vm/zone_reclaim_mode文件对这个参数进行调整。

在申请内存时(内核的get_page_from_freelist()方法中),内核在当前zone内没有足够内存可用的情况下,会根据zone_reclaim_mode的设置来决策是从下一个zone找空闲内存还是在zone内部进行回收。这个值为0时表示可以从下一个zone找可用内存,非0表示在本地回收。这个文件可以设置的值及其含义如下:

  1. echo 0 > /proc/sys/vm/zone_reclaim_mode:意味着关闭zone_reclaim模式,可以从其他zone或NUMA节点回收内存。
  2. echo 1 > /proc/sys/vm/zone_reclaim_mode:表示打开zone_reclaim模式,这样内存回收只会发生在本地节点内。
  3. echo 2 > /proc/sys/vm/zone_reclaim_mode:在本地回收内存时,可以将cache中的脏数据写回硬盘,以回收内存。
  4. echo 4 > /proc/sys/vm/zone_reclaim_mode:可以用swap方式回收内存。

不同的参数配置会在NUMA环境中对其他内存节点的内存使用产生不同的影响,大家可以根据自己的情况进行设置以优化你的应用。默认情况下,zone_reclaim模式是关闭的。这在很多应用场景下可以提高效率,比如文件服务器,或者依赖内存中cache比较多的应用场景。这样的场景对内存cache速度的依赖要高于进程进程本身对内存速度的依赖,所以我们宁可让内存从其他zone申请使用,也不愿意清本地cache。

如果确定应用场景是内存需求大于缓存,而且尽量要避免内存访问跨越NUMA节点造成的性能下降的话,则可以打开zone_reclaim模式。此时页分配器会优先回收容易回收的可回收内存(主要是当前不用的page cache页),然后再回收其他内存。

打开本地回收模式的写回可能会引发其他内存节点上的大量的脏数据写回处理。如果一个内存zone已经满了,那么脏数据的写回也会导致进程处理速度收到影响,产生处理瓶颈。这会降低某个内存节点相关的进程的性能,因为进程不再能够使用其他节点上的内存。但是会增加节点之间的隔离性,其他节点的相关进程运行将不会因为另一个节点上的内存回收导致性能下降。

除非针对本地节点的内存限制策略或者cpuset配置有变化,对swap的限制会有效约束交换只发生在本地内存节点所管理的区域上。

min_unmapped_ratio

这个参数只在NUMA架构的内核上生效。这个值表示NUMA上每个内存区域的pages总数的百分比。在zone_reclaim_mode模式下,只有当相关区域的内存使用达到这个百分比,才会发生区域内存回收。在zone_reclaim_mode设置为4的时候,内核会比较所有的file-backed和匿名映射页,包括swapcache占用的页以及tmpfs文件的总内存使用是否超过这个百分比。其他设置的情况下,只比较基于一般文件的未映射页,不考虑其他相关页。

page-cluster

page-cluster是用来控制从swap空间换入数据的时候,一次连续读取的页数,这相当于对交换空间的预读。这里的连续是指在swap空间上的连续,而不是在内存地址上的连续。因为swap空间一般是在硬盘上,对硬盘设备的连续读取将减少磁头的寻址,提高读取效率。这个文件中设置的值是2的指数。就是说,如果设置为0,预读的swap页数是2的0次方,等于1页。如果设置为3,就是2的3次方,等于8页。同时,设置为0也意味着关闭预读功能。文件默认值为3。我们可以根据我们的系统负载状态来设置预读的页数大小。

swap的相关操纵命令

可以使用mkswap将一个分区或者文件创建成swap空间。swapon可以查看当前的swap空间和启用一个swap分区或者文件。swapoff可以关闭swap空间。我们使用一个文件的例子来演示一下整个操作过程:

制作swap文件:

[root@zorrozou-pc0 ~]# dd if=/dev/zero of=./swapfile bs=1M count=8G
dd: error writing './swapfile': No space left on device
14062+0 records in
14061+0 records out
14744477696 bytes (15 GB, 14 GiB) copied, 44.0824 s, 334 MB/s
[root@zorrozou-pc0 ~]# mkswap swapfile 
mkswap: swapfile: insecure permissions 0644, 0600 suggested.
Setting up swapspace version 1, size = 13.7 GiB (14744473600 bytes)
no label, UUID=a0ac2a67-0f68-4189-939f-4801bec7e8e1

启用swap文件:

[root@zorrozou-pc0 ~]# swapon swapfile 
swapon: /root/swapfile: insecure permissions 0644, 0600 suggested.
[root@zorrozou-pc0 ~]# swapon -s
Filename Type Size Used Priority
/dev/dm-4 partition 33554428 9116 -1
/root/swapfile file 14398900 0 -2

关闭swap空间:

[root@zorrozou-pc0 ~]# swapoff /root/swapfile 
[root@zorrozou-pc0 ~]# swapon -s
Filename Type Size Used Priority
/dev/dm-4 partition 33554428 9116 -1

在使用多个swap分区或者文件的时候,还有一个优先级的概念(Priority)。在swapon的时候,我们可以使用-p参数指定相关swap空间的优先级,值越大优先级越高,可以指定的数字范围是-1到32767。内核在使用swap空间的时候总是先使用优先级高的空间,后使用优先级低的。当然如果把多个swap空间的优先级设置成一样的,那么两个swap空间将会以轮询方式并行进行使用。如果两个swap放在两个不同的硬盘上,相同的优先级可以起到类似RAID0的效果,增大swap的读写效率。另外,编程时使用mlock()也可以将指定的内存标记为不会换出,具体帮助可以参考man 2 mlock。

最后

关于swap的使用建议,针对不同负载状态的系统是不一样的。有时我们希望swap大一些,可以在内存不够用的时候不至于触发oom-killer导致某些关键进程被杀掉,比如数据库业务。也有时候我们希望不要swap,因为当大量进程爆发增长导致内存爆掉之后,会因为swap导致IO跑死,整个系统都卡住,无法登录,无法处理。这时候我们就希望不要swap,即使出现oom-killer也造成不了太大影响,但是不能允许服务器因为IO卡死像多米诺骨牌一样全部死机,而且无法登陆。跑cpu运算的无状态的apache就是类似这样的进程池架构的程序。

所以,swap到底怎么用?要还是不要?设置大还是小?相关参数应该如何配置?是要根据我们自己的生产环境的情况而定的。阅读完本文后希望大家可以明白一些swap的深层次知识。我简单总结一下:

  1. 一个内存剩余还比较大的系统中,是否有可能使用swap?有可能,如果运行中的某个阶段出发了这个条件:zonefile + zonefree <= high_wmark_pages(zone),就可能会swap。
  2. swappiness设置为0就相当于关闭swap么?不是的,关闭swap要使用swapoff命令。swappiness只是在内存发生回收操作的时候用来平衡cache回收和swap交换的一个参数,调整为0意味着,尽量通过清缓存来回收内存。
  3. swappiness设置为100代表系统会尽量少用剩余内存而多使用swap么?不是的,这个值设置为100表示内存发生回收时,从cache回收内存和swap交换的优先级一样。就是说,如果目前需求100M内存,那么较大机率会从cache中清除50M内存,再将匿名页换出50M,把回收到的内存给应用程序使用。但是这还要看cache中是否能有空间,以及swap是否可以交换50m。内核只是试图对它们平衡一些而已。
  4. kswapd进程什么时候开始内存回收?kswapd根据内存水位标记决定是否开始回收内存,如果标记达到low就开始回收,回收到剩余内存达到high标记为止。
  5. 如何查看当前系统的内存水位标记?cat /proc/zoneinfo。

如果对本文有相关问题,可以在我的微博、微信或者博客上联系我。

扫一次只要9毛9,您扫了几次呢?

mm_facetoface_collect_qrcode_1465221734716


大家好,我是Zorro!

如果你喜欢本文,欢迎在微博上搜索“orroz”关注我,地址是:http://weibo.com/orroz

大家也可以在微信上搜索:Linux系统技术 关注我的公众号。

我的所有文章都会沉淀在我的个人博客上,地址是:http://liwei.life。

欢迎使用以上各种方式一起探讨学习,共同进步。

公众号二维码:

Zorro] icon


 

SHELL编程之常用技巧

 

SHELL编程之常用技巧

版权声明:

本文章内容在非商业使用前提下可无需授权任意转载、发布。

转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:orroz

微信公众号:Linux系统技术

前言

本文是shell编程系列的第六篇,集中介绍了bash编程中部分高级编程方法和技巧。通过学习本文内容,可以帮你解决以下问题:

  1. bash可以网络编程么?
  2. .(){ .|.& };. 据说执行这些符号可以死机,那么它们是啥意思?
  3. 你是什么保证crond中的任务不重复执行的?grep一下然后wc算一下个数么?
  4. 受限模式执行bash可以保护什么?
  5. 啥时候会出现subshell?
  6. coproc协进程怎么用?

请刷下面二维码捐赠0.99元。穷佐罗将持续为您奉上高质量的技术文章。多谢支持!

mm_facetoface_collect_qrcode_1465221734716

/dev和/proc目录

dev目录是系统中集中用来存放设备文件的目录。除了设备文件以外,系统中也有不少特殊的功能通过设备的形式表现出来。设备文件是一种特殊的文件,它们实际上是驱动程序的接口。在Linux操作系统中,很多设备都是通过设备文件的方式为进程提供了输入、输出的调用标准,这也符合UNIX的“一切皆文件”的设计原则。所以,对于设备文件来说,文件名和路径其实都不重要,最重要的使其主设备号和辅助设备号,就是用ls -l命令显示出来的原本应该出现在文件大小位置上的两个数字,比如下面命令显示的8和0:

[zorro@zorrozou-pc0 bash]$ ls -l /dev/sda
brw-rw---- 1 root disk 8, 0 5月 12 10:47 /dev/sda

设备文件的主设备号对应了这种设备所使用的驱动是哪个,而辅助设备号则表示使用同一种驱动的设备编号。我们可以使用mknod命令手动创建一个设备文件:

[zorro@zorrozou-pc0 bash]$ sudo mknod harddisk b 8 0 
[zorro@zorrozou-pc0 bash]$ ls -l harddisk 
brw-r--r-- 1 root root 8, 0 5月 18 09:49 harddisk

这样我们就创建了一个设备文件叫harddisk,实际上它跟/dev/sda是同一个设备,因为它们对应的设备驱动和编号都一样。所以这个设备实际上是跟sda相同功能的设备。

系统还给我们提供了几个有特殊功能的设备文件,在bash编程的时候可能会经常用到:

/dev/null:黑洞文件。可以对它重定向如何输出。

/dev/zero:0发生器。可以产生二进制的0,产生多少根使用时间长度有关。我们经常用这个文件来产生大文件进行某些测试,如:

[zorro@zorrozou-pc0 bash]$ dd if=/dev/zero of=./bigfile bs=1M count=1024
1024+0 records in
1024+0 records out
1073741824 bytes (1.1 GB, 1.0 GiB) copied, 0.3501 s, 3.1 GB/s

dd命令也是我们在bash编程中可能会经常使用到的命令。

/dev/random:Linux下的random文件是一个根据计算机背景噪声而产生随机数的真随机数发生器。所以,如果容纳噪声数据的熵池空了,那么对文件的读取会出现阻塞。

/dev/urandom:是一个伪随机数发生器。实际上在Linux的视线中,urandom产生随机数的方法根random一样,只是它可以重复使用熵池中的数据。这两个文件在不同的类unix系统中可能实现方法不同,请注意它们的区别。

/dev/tcp & /dev/udp:这两个神奇的目录为bash编程提供了一种可以进行网络编程的功能。在bash程序中使用/dev/tcp/ip/port的方式就可以创建一个scoket作为客户端去连接服务端的ip:port。我们用一个检查http协议的80端口是否打开的例子来说明它的使用方法:

[zorro@zorrozou-pc0 bash]$ cat tcp.sh
#!/bin/bash

ipaddr=127.0.0.1
port=80

if ! exec 5<> /dev/tcp/$ipaddr/$port
then
exit 1
fi

echo -e "GET / HTTP/1.0\n" >&5

cat <&5

ipaddr的部分还可以写一个主机名。大家可以用此脚本分别在本机打开web服务和不打开的情况下分别执行观察是什么效果。

/proc是另一个我们经常使用的目录。这个目录完全是内核虚拟的。内核将一些系统信息都放在/proc目录下一文件和文本的方式显示出来,如:/proc/cpuinfo、/proc/meminfo。我们可以使用man 5 proc来查询这个目录下文件的作用。

函数和递归

我们已经接触过函数的概念了,在bash编程中,函数无非是将一串命令起了个名字,后续想要调用这一串命令就可以直接写函数的名字了。在语法上定义一个函数的方法是:

name () compound-command [redirection]
function name [()] compound-command [redirection]

我们可以加function关键字显式的定义一个函数,也可以不加。函数在定义的时候可以直接在后面加上重定向的处理。这里还需要特殊说明的是函数的参数处理和局部变量,请看下面脚本:

[zorro@zorrozou-pc0 bash]$ cat function.sh |awk '{print "\t"$0}'
#!/bin/bash

aaa=1000

arg_proc () {
echo "Function begin:"
local aaa=2000
echo $1
echo $2
echo $3
echo $*
echo $@
echo $aaa
echo "Function end!"
}

echo "Script bugin:"
echo $1
echo $2
echo $3
echo $*
echo $@
echo $aaa

arg_proc aaa bbb ccc ddd eee fff

echo $1
echo $2
echo $3
echo $*
echo $@
echo $aaa
echo "Script end!"

我们带-x参数执行一下:

+ aaa=1000
+ echo 'Script bugin:'
Script bugin:
+ echo 111
111
+ echo 222
222
+ echo 333
333
+ echo 111 222 333 444 555
111 222 333 444 555
+ echo 111 222 333 444 555
111 222 333 444 555
+ echo 1000
1000
+ arg_proc aaa bbb ccc ddd eee fff
+ echo 'Function begin:'
Function begin:
+ local aaa=2000
+ echo aaa
aaa
+ echo bbb
bbb
+ echo ccc
ccc
+ echo aaa bbb ccc ddd eee fff
aaa bbb ccc ddd eee fff
+ echo aaa bbb ccc ddd eee fff
aaa bbb ccc ddd eee fff
+ echo 2000
2000
+ echo 'Function end!'
Function end!
+ echo 111
111
+ echo 222
222
+ echo 333
333
+ echo 111 222 333 444 555
111 222 333 444 555
+ echo 111 222 333 444 555
111 222 333 444 555
+ echo 1000
1000
+ echo 'Script end!'
Script end!

观察整个执行过程可以发现,函数的参数适用方法跟脚本一样,都可以使用$n、$*、$@这些符号来处理。而且函数参数跟函数内部使用local定义的局部变量效果一样,都是只在函数内部能看到。函数外部看不到函数里定义的局部变量,当函数内部的局部变量和外部的全局变量名字相同时,函数内只能取到局部变量的值。当函数内部没有定义跟外部同名的局部变量的时候,函数内部也可以看到全局变量。

bash编程支持递归调用函数,跟其他编程语言不同的地方是,bash还可以递归的调用自身,这在某些编程场景下非常有用。我们先来看一个递归的简单例子:

[zorro@zorrozou-pc0 bash]$ cat recurse.sh
#!/bin/bash

read_dir () {
for i in $1/*
do
if [ -d $i ]
then
read_dir $i
else
echo $i
fi
done

}

read_dir $1

这个脚本可以遍历一个目录下所有子目录中的非目录文件。关于递归,还有一个经典的例子,fork炸弹:

.(){ .|.& };.

这一堆符号看上去很令人费解,我们来解释一下每个符号的含义:根据函数的定义语法,我们知道.(){}的意思是,定义一个函数名子叫“.”。虽然系统中又个内建命令也叫.,就是source命令,但是我们也知道,当函数和内建命令名字冲突的时候,bash首先会将名字当成是函数来解释。在{}包含的函数体中,使用了一个管道连接了两个点,这里的第一个.就是函数的递归调用,我们也知道了使用管道的时候会打开一个subshell的子进程,所以在这里面就递归的打开了子进程。{}后面的分号只表示函数定义完毕的结束符,在之后就是调用函数名执行的.,之后函数开始递归的打开自己,去产生子进程,直到系统崩溃为止。

请刷下面二维码捐赠0.99元。穷佐罗将持续为您奉上高质量的技术文章。多谢支持!

mm_facetoface_collect_qrcode_1465221734716

bash并发编程和flock

在shell编程中,需要使用并发编程的场景并不多。我们倒是经常会想要某个脚本不要同时出现多次同时执行,比如放在crond中的某个周期任务,如果执行时间较长以至于下次再调度的时间间隔,那么上一个还没执行完就可能又打开一个,这时我们会希望本次不用执行。本质上讲,无论是只保证任何时候系统中只出现一个进程还是多个进程并发,我们需要对进程进行类似的控制。因为并发的时候也会有可能产生竞争条件,导致程序出问题。

我们先来看如何写一个并发的bash程序。在前文讲到作业控制和wait命令使用的时候,我们就已经写了一个简单的并发程序了,我们这次让它变得复杂一点。我们写一个bash脚本,创建一个计数文件,并将里面的值写为0。然后打开100个子进程,每个进程都去读取这个计数文件的当前值,并加1写回去。如果程序执行正确,最后里面的值应该是100,因为每个子进程都会累加一个1写入文件,我们来试试:

[zorro@zorrozou-pc0 bash]$ cat racing.sh
#!/bin/bash

countfile=/tmp/count

if ! [ -f $countfile ]
then
echo 0 > $countfile
fi

do_count () {
read count < $countfile
echo $((++count)) > $countfile
}

for i in `seq 1 100`
do
do_count &
done

wait

cat $countfile

rm $countfile

我们再来看看这个程序的执行结果:

[zorro@zorrozou-pc0 bash]$ ./racing.sh 
26
[zorro@zorrozou-pc0 bash]$ ./racing.sh 
13
[zorro@zorrozou-pc0 bash]$ ./racing.sh 
34
[zorro@zorrozou-pc0 bash]$ ./racing.sh 
25
[zorro@zorrozou-pc0 bash]$ ./racing.sh 
45
[zorro@zorrozou-pc0 bash]$ ./racing.sh 
5

多次执行之后,每次得到的结果都不一样,也没有一次是正确的结果。这就是典型的竞争条件引起的问题。当多个进程并发的时候,如果使用的共享的资源,就有可能会造成这样的问题。这里的竞争调教就是:当某一个进程读出文件值为0,并加1,还没写回去的时候,如果有别的进程读了文件,读到的还是0。于是多个进程会写1,以及其它的数字。解决共享文件的竞争问题的办法是使用文件锁。每个子进程在读取文件之前先给文件加锁,写入之后解锁,这样临界区代码就可以互斥执行了:

[zorro@zorrozou-pc0 bash]$ cat flock.sh
#!/bin/bash

countfile=/tmp/count

if ! [ -f $countfile ]
then
echo 0 > $countfile
fi

do_count () {
exec 3< $countfile
#对三号描述符加互斥锁
flock -x 3
read -u 3 count
echo $((++count)) > $countfile
#解锁
flock -u 3
#关闭描述符也会解锁
exec 3>&-
}

for i in `seq 1 100`
do
do_count &
done

wait

cat $countfile

rm $countfile
[zorro@zorrozou-pc0 bash]$ ./flock.sh 
100

对临界区代码进行加锁处理之后,程序执行结果正确了。仔细思考一下程序之后就会发现,这里所谓的临界区代码由加锁前的并行,变成了加锁后的串行。flock的默认行为是,如果文件之前没被加锁,则加锁成功返回,如果已经有人持有锁,则加锁行为会阻塞,直到成功加锁。所以,我们也可以利用互斥锁的这个特征,让bash脚本不会重复执行。

[zorro@zorrozou-pc0 bash]$ cat repeat.sh
#!/bin/bash

exec 3> /tmp/.lock

if ! flock -xn 3
then
echo "already running!"
exit 1
fi

echo "running!"
sleep 30
echo "ending"

flock -u 3
exec 3>&-
rm /tmp/.lock

exit 0

-n参数可以让flock命令以非阻塞方式探测一个文件是否已经被加锁,所以可以使用互斥锁的特点保证脚本运行的唯一性。脚本退出的时候锁会被释放,所以这里可以不用显式的使用flock解锁。flock除了-u参数指定文件描述符锁文件以外,还可以作为执行命令的前缀使用。这种方式非常适合直接在crond中方式所要执行的脚本重复执行。如:

*/1 * * * * /usr/bin/flock -xn /tmp/script.lock -c '/home/bash/script.sh'

关于flock的其它参数,可以man flock找到说明。

受限bash

以受限模式执行bash程序,有时候是很有必要的。这种模式可以保护我们的很多系统环境不受bash程序的误操作影响。启动受限模式的bash的方法是使用-r参数,或者也可以rbash的进程名方式执行bash。受限模式的bash和正常bash时间的差别是:

  1. 不能使用cd命令改变当前工作目录。
  2. 不能改变SHELL、PATH、ENV和BASH_ENV环境变量。
  3. 不能调用含有/的命令路径。
  4. 不能使用.执行带有/字符的命令路径。
  5. 不能使用hash命令的-p参数指定一个带斜杠\的参数。
  6. 不能在shell环境启动的时候加载函数的定义。
  7. 不能检查SHELLOPTS变量的内容。
  8. 不能使用>, >|, <>, >&, &>和 >>重定向操作符。
  9. 不能使用exec命令使用一个新程序替换当前执行的bash进程。
  10. enable内建命令不能使用-f、-d参数。
  11. 不可以使用enable命令打开或者关闭内建命令。
  12. command命令不可以使用-p参数。
  13. 不能使用set +r或者set +o restricted命令关闭受限模式。

测试一个简单的受限模式:

[zorro@zorrozou-pc0 bash]$ cat restricted.sh 
#!/bin/bash

set -r

cd /tmp
[zorro@zorrozou-pc0 bash]$ ./restricted.sh 
./restricted.sh: line 5: cd: restricted

subshell

我们前面接触过subshell的概念,我们之前说的是,当一个命令放在()中的时候,bash会打开一个子进程去执行相关命令,这个子进程实际上是另一个bash环境,叫做subshell。当然包括放在()中执行的命令,bash会在以下情况下打开一个subshell执行命令:

  1. 使用&作为命令结束提交了作业控制任务时。
  2. 使用|连接的命令会在subshell中打开。
  3. 使用()封装的命令。
  4. 使用coproc(bash 4.0版本之后支持)作为前缀执行的命令。
  5. 要执行的文件不存在或者文件存在但不具备可执行权限的时候,这个执行过程会打开一个subshell执行。

在subshell中,有些事情需要注意。subshell中的$$取到的仍然是父进程bash的pid,如果想要取到subshell的pid,可以使用BASHPID变量:

[zorro@zorrozou-pc0 bash]$ echo $$ ;echo $BASHPID && (echo $$;echo $BASHPID)
5484
5484
5484
24584

可以使用BASH_SUBSHELL变量的值来检查当前环境是不是在subshell中,这个值在非subshell中是0;每进入一层subshell就加1。

[zorro@zorrozou-pc0 bash]$ echo $BASH_SUBSHELL;(echo $BASH_SUBSHELL;(echo $BASH_SUBSHELL))
0
1
2

在subshell中做的任何操作都不会影响父进程的bash执行环境。subshell除了PID和trap相关设置外,其他的环境都跟父进程是一样的。subshell的trap设置跟父进程刚启动的时候还没做trap设置之前一样。

协进程coprocess

在bash 4.0版本之后,为我们提供了一个coproc关键字可以支持协进程。协进程提供了一种可以上bash移步执行另一个进程的工作模式,实际上跟作业控制类似。严格来说,bash的协进程就是使用作业控制作为实现手段来做的。它跟作业控制的区别仅仅在于,协进程的标准输入和标准输出都在调用协进程的bash中可以取到文件描述符,而作业控制进程的标准输入和输出都是直接指向终端的。我们来看看使用协进程的语法:

coproc [NAME] command [redirections]

使用coproc作为前缀,后面加执行的命令,可以将命令放到作业控制里执行。并且在bash中可以通过一些方法查看到协进程的pid和使用它的输入和输出。例子:

zorro@zorrozou-pc0 bash]$ cat coproc.sh
#!/bin/bash
#例一:简单命令使用
#简单命令使用不能通过NAME指定协进程的名字,此时进程的名字统一为:COPROC。
coproc tail -3 /etc/passwd
echo $COPROC_PID
exec 0<&${COPROC[0]}-
cat

#例二:复杂命令使用
#此时可以使用NAME参数指定协进程名称,并根据名称产生的相关变量获得协进程pid和描述符。

coproc _cat { tail -3 /etc/passwd; }
echo $_cat_PID
exec 0<&${_cat[0]}-
cat

#例三:更复杂的命令以及输入输出使用
#协进程的标准输入描述符为:NAME[1],标准输出描述符为:NAME[0]。

coproc print_username {
while read string
do
[ "$string" = "END" ] && break
echo $string | awk -F: '{print $1}'
done
}

echo "aaa:bbb:ccc" 1>&${print_username[1]}
echo ok

read -u ${print_username[0]} username

echo $username

cat /etc/passwd >&${print_username[1]}
echo END >&${print_username[1]}

while read -u ${print_username[0]} username
do
echo $username
done

执行结果:

[zorro@zorrozou-pc0 bash]$ ./coproc.sh
31953
jerry:x:1001:1001::/home/jerry:/bin/bash
systemd-coredump:x:994:994:systemd Core Dumper:/:/sbin/nologin
netdata:x:134:134::/var/cache/netdata:/bin/nologin
31955
jerry:x:1001:1001::/home/jerry:/bin/bash
systemd-coredump:x:994:994:systemd Core Dumper:/:/sbin/nologin
netdata:x:134:134::/var/cache/netdata:/bin/nologin
ok
aaa
root
bin
daemon
mail
ftp
http
uuidd
dbus
nobody
systemd-journal-gateway
systemd-timesync
systemd-network
systemd-bus-proxy
systemd-resolve
systemd-journal-remote
systemd-journal-upload
polkitd
avahi
colord
rtkit
gdm
usbmux
git
gnome-initial-setup
zorro
nvidia-persistenced
ntp
jerry
systemd-coredump
netdata

最后

本文主要介绍了一些bash编程的常用技巧,主要包括的知识点为:

  1. /dev/和/proc目录的使用。
  2. 函数和递归。
  3. 并发编程和flock。
  4. 受限bash。
  5. subshell。
  6. 协进程。

至此,我们的bash编程系列就算结束了。当然,shell其实到现在才刚刚开始。毕竟我们要真正实现有用的bash程序,还需要积累大量命令的使用。本文篇幅有限,就不探讨外部命令的详细使用方法和技巧了。希望这一系列内容对大家进一步深入了解bash编程有帮助。

如果有相关问题,可以在我的微博、微信或者博客上联系我。

请刷下面二维码捐赠0.99元。穷佐罗将持续为您奉上高质量的技术文章。多谢支持!

mm_facetoface_collect_qrcode_1465221734716


大家好,我是Zorro!

如果你喜欢本文,欢迎在微博上搜索“orroz”关注我,地址是:http://weibo.com/orroz

大家也可以在微信上搜索:Linux系统技术 关注我的公众号。

我的所有文章都会沉淀在我的个人博客上,地址是:http://liwei.life。

欢迎使用以上各种方式一起探讨学习,共同进步。

公众号二维码:

Zorro] icon


 

SHELL编程之内建命令

 

SHELL编程之内建命令

版权声明:

本文章内容在非商业使用前提下可无需授权任意转载、发布。

转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:orroz

微信公众号:Linux系统技术

前言

本文是shell编程系列的第五篇,集中介绍了bash相关内建命令的使用。通过学习本文内容,可以帮你解决以下问题:

  1. 什么是内建命令?为什么要有内建命令?
  2. 为啥echo 111 222 333 444 555| read -a test之后echo ${test[*]}不好使?
  3. ./script和. script有啥区别?
  4. 如何让让kill杀不掉你的bash脚本?
  5. 如何更优雅的处理bash的命令行参数?

为什么要有内建命令

内建命令是指bash内部实现的命令。bash在执行这些命令的时候不同于一般外部命令的fork、exec、wait的处理过程,这内建功能本身不需要打开一个子进程执行,而是bash本身就可以进行处理。分析外部命令的执行过程我们可以理解内建命令的重要性,外建命令都会打开一个子进程执行,所以有些功能没办法通过外建命令实现。比如当我们想改变当前bash进程的某些环境的时候,如:切换当前进程工作目录,如果打开一个子进程,切换之后将会改变子进程的工作目录,与当前bash没关系。所以内建命令基本都是从必须放在bash内部实现的命令。bash所有的内建命令只有50多个,绝大多数的命令我们在之前的介绍中都已经使用过了。下面我们就把它们按照使用的场景分类之后,分别介绍一下在bash编程中可能会经常用到的内建命令。

输入输出

对于任何编程语言来说,程序跟文件的输入输出都是非常重要的内容,bash编程当然也不例外。所有的shell编程与其他语言在IO处理这一块的最大区别就是,shell可以直接使用命令进行处理,而其他语言基本上都要依赖IO处理的库和函数进行处理。所以对于shell编程来说,IO处理的相关代码写起来要简单的多。本节我们只讨论bash内建的IO处理命令,而外建的诸如grep、sed、awk这样的高级处理命令不在本文的讨论范围内。

source

.

以上两个命令:source和.实际上是同一个内建命令,它们的功能完全一样,只是两种不同写法。我们都应该见过这样一种写法,如:

for i in /etc/profile.d/*.sh; do
    if [ -r "$i" ]; then
        if [ "$PS1" ]; then
            . "$i"
        else
            . "$i" >/dev/null 2>&1
        fi
    fi
done

这里的”. $i”实际上就是source $i。这个命令的含义是:读取文件的内容,并在当前bash环境下将其内容当命令执行。注意,这与输入一个可执行脚本的路径的执行方式是不同的。路径执行的方式会打开一个子进程的bash环境去执行脚本中的内容,而source方式将会直接在当前bash环境中执行其内容。所以这种方式主要用于想引用一个脚本中的内容用来改变当前bash环境。如:加载环境变量配置脚本或从另一个脚本中引用其定义的函数时。我们可以通过如下例子来理解一下这个内建命令的作用:

[zorro@zorrozou-pc0 bash]$ cat source.sh 
#!/bin/bash

aaa=1000

echo $aaa
echo $$
[zorro@zorrozou-pc0 bash]$ ./source.sh 
1000
27051
[zorro@zorrozou-pc0 bash]$ echo $aaa

[zorro@zorrozou-pc0 bash]$ . source.sh 
1000
17790
[zorro@zorrozou-pc0 bash]$ echo $aaa
1000
[zorro@zorrozou-pc0 bash]$ echo $$
17790

我们可以通过以上例子中的$aaa变量看到当前bash环境的变化,可以通过$$变量,看到不同执行过程的进程环境变化。

read

这个命令可以让bash从标准输入读取输字符串到一个变量中。用法如下:

[zorro@zorrozou-pc0 bash]$ cat input.sh 
#!/bin/bash

read -p "Login: " username

read -p "Passwd: " password

echo $username

echo $password

程序执行结果:

[zorro@zorrozou-pc0 bash]$ ./input.sh 
Login: zorro
Passwd: zorro
zorro
zorro

我们可以利用read命令实现一些简单的交互程序。read自带提示输出功能,-p参数可以让read在读取输入之前先打印一个字符串。read命令除了可以读取输入并赋值一个变量以外,还可以赋值一个数组,比如我们想把一个命令的输出读到一个数组中,使用方法是:

[zorro@zorrozou-pc0 bash]$ cat read.sh 
#!/bin/bash


read -a test

echo ${test[*]}

执行结果:

[zorro@zorrozou-pc0 bash]$ ./read.sh 
111 222 333 444 555
111 222 333 444 555

输入为:111 222 333 444 555,就会打印出整个数组列表。

mapfile

readarray

这两个命令又是同一个命令的两种写法。它们的功能是,将一个文本文件直接变成一个数组,每行作为数组的一个元素。这对某些程序的处理是很方便的。尤其是当你要对某些文件进行全文的分析或者处理的时候,比一行一行读进来处理方便的多。用法:

[zorro@zorrozou-pc0 bash]$ cat mapfile.sh 
#!/bin/bash

exec 3< /etc/passwd

mapfile -u 3 passwd 

exec 3<&-

echo ${#passwd}

for ((i=0;i<${#passwd};i++))
do
    echo ${passwd[$i]}
done

程序输出:

[zorro@zorrozou-pc0 bash]$ ./mapfile.sh 
32
root:x:0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/usr/bin/nologin
daemon:x:2:2:daemon:/:/usr/bin/nologin
...

本例子中使用了-u参数,表示让mapfile或readarray命令从一个文件描述符读取,如果不指定文件描述符,命令将默认从标准输入读取。所以很多人可能习惯用管道的方式读取,如:

[zorro@zorrozou-pc0 bash]$ cat /etc/passwd|mapfile passwd
[zorro@zorrozou-pc0 bash]$ echo ${passwd[*]}

但是最后却发现passwd变量根本不存在。这个原因是:如果内建命令放到管道环境中执行,那么bash会给它创建一个subshell进行处理。于是创建的数组实际上与父进程没有关系。这点是使用内建命令需要注意的一点。同样,read命令也可能会出现类似的使用错误。如:

echo 111 222 333 444 555| read -a test

执行完之后,我们在bash脚本环境中仍然无法读取到test变量的值,也是同样的原因。

mapfile的其他参数,大家可以自行参考help mapfile或help readarray取得帮助。

echo

printf

这两个都是用来做输出的命令,其中echo是我们经常使用的,就不啰嗦了,具体参数可以help echo。printf命令是一个用来进行格式化输出的命令,跟C语言或者其他语言的printf格式化输出的方法都类似,比如:

[zorro@zorrozou-pc0 bash]$ printf "%d\t%s %f\n" 123 zorro 1.23
123 zorro 1.230000

使用很简单,具体也请参见:help printf。


如果本文有用,请刷下面二维码捐赠0.99元。穷佐罗将持续为您奉上高质量的技术文章。多谢支持!

mm_facetoface_collect_qrcode_1465221734716


作业控制

作业控制指的是jobs功能。一般情况下bash执行命令的方式是打开一个子进程并wait等待其退出,所以bash在等待一个命令执行的过程中不能处理其他命令。而jobs功能给我们提供了一种办法,可以让bash不用显示的等待子进程执行完毕后再处理别的命令,在命令行中使用这个功能的方法是在命令后面加&符号,表明进程放进作业控制中处理,如:

[zorro@zorrozou-pc0 bash]$ sleep 3000 &
[1] 30783
[zorro@zorrozou-pc0 bash]$ sleep 3000 &
[2] 30787
[zorro@zorrozou-pc0 bash]$ sleep 3000 &
[3] 30791
[zorro@zorrozou-pc0 bash]$ sleep 3000 &
[4] 30795
[zorro@zorrozou-pc0 bash]$ sleep 3000 &
[5] 30799

我们放了5个sleep进程进入jobs作业控制。大家可以当作这是bash提供给我们的一种“并发处理”方式。此时我们可以使用jobs命令查看作业系统中有哪些进程在执行:

[zorro@zorrozou-pc0 bash]$ jobs
[1]   Running                 sleep 3000 &
[2]   Running                 sleep 3000 &
[3]   Running                 sleep 3000 &
[4]-  Running                 sleep 3000 &
[5]+  Running                 sleep 3000 &

除了数字外,这里还有+和-号标示。+标示当前作业任务,-表示备用的当前作业任务。所谓的当前作业,就是最后一个被放到作业控制中的进程,而备用的则是当前进程如果退出,那么备用的就会变成当前的。这些jobs进程可以使用编号和PID的方式控制,如:

[zorro@zorrozou-pc0 bash]$ kill %1
[1]   Terminated              sleep 3000
[zorro@zorrozou-pc0 bash]$ jobs
[2]   Running                 sleep 3000 &
[3]   Running                 sleep 3000 &
[4]-  Running                 sleep 3000 &
[5]+  Running                 sleep 3000 &

表示杀掉1号作业任务,还可以使用kill %+或者kill %-以及kill %%(等同于%+)。除了可以kill这些进程以外,bash还提供了其他控制命令:

fg
bg

将指定的作业进程回到前台让当前bash去wait。如:

[zorro@zorrozou-pc0 bash]$ fg %5
sleep 3000

于是当前bash又去“wait”5号作业任务了。当然fg后面也可以使用%%、%+、%-等符号,如果fg不加参数效果跟fg %+也是一样的。让一个当前bash正在wait的进程回到作业控制,可以使用ctrl+z快捷键,这样会让这个进程处于stop状态:

[zorro@zorrozou-pc0 bash]$ fg %5
sleep 3000
^Z
[5]+  Stopped                 sleep 3000

[zorro@zorrozou-pc0 bash]$ jobs
[2]   Running                 sleep 3000 &
[3]   Running                 sleep 3000 &
[4]-  Running                 sleep 3000 &
[5]+  Stopped                 sleep 3000

这个进程目前是stopped的,想让它再运行起来可以使用bg命令:

[zorro@zorrozou-pc0 bash]$ bg %+
[5]+ sleep 3000 &
[zorro@zorrozou-pc0 bash]$ jobs
[2]   Running                 sleep 3000 &
[3]   Running                 sleep 3000 &
[4]-  Running                 sleep 3000 &
[5]+  Running                 sleep 3000 &

disown

disown命令可以让一个jobs作业控制进程脱离作业控制,变成一个“野”进程:

[zorro@zorrozou-pc0 bash]$ disown 
[zorro@zorrozou-pc0 bash]$ jobs
[2]   Running                 sleep 3000 &
[3]-  Running                 sleep 3000 &
[4]+  Running                 sleep 3000 &

直接回车的效果跟diswon %+是一样的,也是处理当前作业进程。这里要注意的是,disown之后的进程仍然是还在运行的,只是bash不会wait它,jobs中也不在了。

信号处理

进程在系统中免不了要处理信号,即使是bash。我们至少需要使用命令给别进程发送信号,于是就有了kill命令。kill这个命令应该不用多说了,但是需要大家更多理解的是信号的概念。大家可以使用kill -l命令查看信号列表:

[zorro@zorrozou-pc0 bash]$ kill -l
 1) SIGHUP   2) SIGINT   3) SIGQUIT  4) SIGILL   5) SIGTRAP
 6) SIGABRT  7) SIGBUS   8) SIGFPE   9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG  24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF 28) SIGWINCH    29) SIGIO   30) SIGPWR
31) SIGSYS  34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX    

每个信号的意思以及进程接收到相关信号的默认行为了这个内容大家可以参见《UNIX环境高级编程》。我们在此先只需知道,常用的信号有2号(crtl c就是发送2号信号),15号(kill默认发送),9号(著名的kill -9)这几个就可以了。其他我们还需要知道,这些信号绝大多数是可以被进程设置其相应行为的,除了9号和19号信号。这也是为什么我们一般用kill直接无法杀掉的进程都会再用kill -9试试的原因。

那么既然进程可以设置信号的行为,bash中如何处理呢?使用trap命令。方法如下:

[zorro@zorrozou-pc0 bash]$ cat trap.sh 
#!/bin/bash

trap 'echo hello' 2 15
trap 'exit 17' 3

while :
do
    sleep 1
done

trap命令的格式如下:

trap [-lp] [[arg] signal_spec ...]

在我们的例子中,第一个trap命令的意思是,定义针对2号和15号信号的行为,当进程接收到这两个信号的时候,将执行echo hello。第二个trap的意思是,如果进程接收到3号信号将执行exit 17,以17为返回值退出进程。然后我们来看一下进程执行的效果:

[zorro@zorrozou-pc0 bash]$ ./trap.sh 
^Chello
^Chello
^Chello
^Chello
^Chello
hello
hello

此时按ctrl+c和kill这个bash进程都会让进程打印hello。3号信号可以用ctrl+\发送:

[zorro@zorrozou-pc0 bash]$ ./trap.sh 
^Chello
^Chello
^Chello
^Chello
^Chello
hello
hello
^\Quit (core dumped)
[zorro@zorrozou-pc0 bash]$ echo $?
17

此时进程退出,返回值是17,而不是128+3=131。这就是trap命令的用法。

suspend

bash还提供了一种让bash执行暂停并等待信号的功能,就是suspend命令。它等待的是18号SIGCONT信号,这个信号本身的含义就是让一个处在T(stop)状态的进程恢复运行。使用方法:

[zorro@zorrozou-pc0 bash]$ cat suspend.sh 
#!/bin/bash

pid=$$

echo "echo $pid"
#打开jobs control功能,在没有这个功能suspend无法使用,脚本中默认此功能关闭。
#我们并不推荐在脚本中开启此功能。
set -m

echo "Begin!"

echo $-

echo "Enter suspend stat:"

#让一个进程十秒后给本进程发送一个SIGCONT信号
( sleep 10 ; kill -18 $pid ) &
#本进程进入等待
suspend 

echo "Get SIGCONT and continue running."

echo "End!"

执行效果:

[zorro@zorrozou-pc0 bash]$ ./suspend.sh 
echo 31833
Begin!
hmB
Enter suspend stat:

[1]+  Stopped                 ./suspend.sh

十秒之后:

[zorro@zorrozou-pc0 bash]$ 
[zorro@zorrozou-pc0 bash]$ Get SIGCONT and continue running.
End!

以上是suspend在脚本中的使用方法。另外,suspend默认不能在非loginshell中使用,如果使用,需要加-f参数。

进程控制

bash中也实现了基本的进程控制方法。主要的命令有exit,exec,logout,wait。其中exit我们已经了解了。logout的功能跟exit实际上差不多,区别只是logout是专门用来退出login方式的bash的。如果bash不是login方式执行的,logout会报错:

[zorro@zorrozou-pc0 bash]$ cat logout.sh 
#!/bin/bash

logout
[zorro@zorrozou-pc0 bash]$ ./logout.sh 
./logout.sh: line 3: logout: not login shell: use `exit'

wait

wait命令的功能是用来等待jobs作业控制进程退出的。因为一般进程默认行为就是要等待其退出之后才能继续执行。wait可以等待指定的某个jobs进程,也可以等待所有jobs进程都退出之后再返回,实际上wait命令在bash脚本中是可以作为类似“屏障”这样的功能使用的。考虑这样一个场景,我们程序在运行到某一个阶段之后,需要并发的执行几个jobs,并且一定要等到这些jobs都完成工作才能继续执行,但是每个jobs的运行时间又不一定多久,此时,我们就可以用这样一个办法:

[zorro@zorrozou-pc0 bash]$ cat wait.sh 
#!/bin/bash

echo "Begin:"

(sleep 3; echo 3) &
(sleep 5; echo 5) &
(sleep 7; echo 7) &
(sleep 9; echo 9) &

wait

echo parent continue

sleep 3

echo end!
[zorro@zorrozou-pc0 bash]$ ./wait.sh 
Begin:
3
5
7
9
parent continue
end!

通过这个例子可以看到wait的行为:在不加任何参数的情况下,wait会等到所有作业控制进程都退出之后再回返回,否则就会一直等待。当然,wait也可以指定只等待其中一个进程,可以指定pid和jobs方式的作业进程编号,如%3,就变成了:

[zorro@zorrozou-pc0 bash]$ cat wait.sh 
#!/bin/bash

echo "Begin:"

(sleep 3; echo 3) &
(sleep 5; echo 5) &
(sleep 7; echo 7) &
(sleep 9; echo 9) &

wait %3

echo parent continue

sleep 3

echo end!
[zorro@zorrozou-pc0 bash]$ ./wait.sh 
Begin:
3
5
7
parent continue
9
end!

exec

我们已经在重定向那一部分讲过exec处理bash程序的文件描述符的使用方法了,在此补充一下它是如何执行命令的。这个命令的执行过程跟exec族的函数功能是一样的:将当前进程的执行镜像替换成指定进程的执行镜像。还是举例来看:

[zorro@zorrozou-pc0 bash]$ cat exec.sh 
#!/bin/bash

echo "Begin:"

echo "Before exec:"

exec ls /etc/passwd

echo "After exec:"

echo "End!"
[zorro@zorrozou-pc0 bash]$ ./exec.sh 
Begin:
Before exec:
/etc/passwd

实际上这个脚本在执行到exec ls /etc/passwd之后,bash进程就已经替换为ls进程了,所以后续的echo命令都不会执行,ls执行完,这个进程就完全退出了。

命令行参数处理

我们已经学习过使用shift方式处理命令行参数了,但是这个功能还是比较简单,它每次执行就仅仅是将参数左移一位而已,将本次的$2变成下次的$1。bash也给我们提供了一个更为专业的命令行参数处理方法,这个命令是getopts。

我们都知道一般的命令参数都是通过-a、-b、-c这样的参数来指定各种功能的,如果我们想要实现这样的功能,只单纯使用shift这样的方式手工处理将会非常麻烦,而且还不能支持让-a -b写成-ab这样的方式。bash跟其他语言一样,提供了getopts这样的方法来帮助我们处理类似的问题,如:

[zorro@zorrozou-pc0 bash]$ cat getopts.sh 
#!/bin/bash

#getopts的使用方式:字母后面带:的都是需要执行子参数的,如:-c xxxxx -e xxxxxx,后续可以用$OPTARG变量进行判断。
#getopts会将输入的-a -b分别赋值给arg变量,以便后续判断。
while getopts "abc:de:f" arg
do
    case $arg in
        a)
        echo "aaaaaaaaaaaaaaa"
        ;;
        b)
        echo "bbbbbbbbbbbbbbb"
        ;;
        c)
        echo "c: arg:$OPTARG"
        ;;
        d)
        echo "ddddddddddddddd"
        ;;
        e)
        echo "e: arg:$OPTARG"
        ;;
        f)
        echo "fffffffffffffff"
        ;;
        ?)
        echo "$arg :no this arguments!"
    esac
done

以下为程序输出:

[zorro@zorrozou-pc0 bash]$ ./getopts.sh -a -bd -c zorro -e jerry 
aaaaaaaaaaaaaaa
bbbbbbbbbbbbbbb
ddddddddddddddd
c: arg:zorro
e: arg:jerry
[zorro@zorrozou-pc0 bash]$ ./getopts.sh -c xxxxxxx
c: arg:xxxxxxx
[zorro@zorrozou-pc0 bash]$ ./getopts.sh -a
aaaaaaaaaaaaaaa
[zorro@zorrozou-pc0 bash]$ ./getopts.sh -f
fffffffffffffff
[zorro@zorrozou-pc0 bash]$ ./getopts.sh -g
./getopts.sh: illegal option -- g
unknow argument!

getopts只能处理段格式参数,如:-a这样的。不能支持的是如–login这种长格式参数。实际上我们的系统中还给了一个getopt命令,可以处理长格式参数。这个命令不是内建命令,使用方法跟getopts类似,大家可以自己man getopt近一步学习这个命令的使用,这里就不再赘述了。

进程环境

内建命令中最多的就是关于进程环境的配置的相关命令,当然绝大多数我们之前已经会用了。它们包括:alias、unalias、cd、declare、typeset、dirs、enable、export、hash、history、popd、pushd、local、pwd、readonly、set、unset、shopt、ulimit、umask。

我们在这需要简单说明的命令有:

declare

typeset

这两个命令用来声明或显示进程的变量或函数相关信息和属性。如:

declare -a array:可以声明一个数组变量。

declare -A array:可以声明一个关联数组。

declare -f func:可以声明或查看一个函数。

其他常用参数可以help declare查看。

enable

可以用来打开或者关闭某个内建命令的功能。

dirs

popd

pushd

dirs、popd、pushd可以用来操作目录栈。目录栈是bash提供的一种纪录曾经去过的相关目录的缓存数据结构,可以方便的使操作者在多个深层次的目录中方便的跳转。使用演示:

显示当前目录栈:

[zorro@zorrozou-pc0 dirstack]$ dirs
~/bash/dirstack

只有一个当前工作目录。将aaa加入目录栈:

[zorro@zorrozou-pc0 dirstack]$ pushd aaa
~/bash/dirstack/aaa ~/bash/dirstack

pushd除了将目录加入了目录栈外,还改变了当前工作目录。

[zorro@zorrozou-pc0 aaa]$ pwd
/home/zorro/bash/dirstack/aaa

将bbb目录加入目录栈:

[zorro@zorrozou-pc0 aaa]$ pushd ../bbb/
~/bash/dirstack/bbb ~/bash/dirstack/aaa ~/bash/dirstack
[zorro@zorrozou-pc0 bbb]$ dirs
~/bash/dirstack/bbb ~/bash/dirstack/aaa ~/bash/dirstack
[zorro@zorrozou-pc0 bbb]$ pwd
/home/zorro/bash/dirstack/bbb

加入ccc、ddd、eee目录:

[zorro@zorrozou-pc0 bbb]$ pushd ../ccc
~/bash/dirstack/ccc ~/bash/dirstack/bbb ~/bash/dirstack/aaa ~/bash/dirstack
[zorro@zorrozou-pc0 ccc]$ pushd ../ddd
~/bash/dirstack/ddd ~/bash/dirstack/ccc ~/bash/dirstack/bbb ~/bash/dirstack/aaa ~/bash/dirstack
[zorro@zorrozou-pc0 ddd]$ pushd ../eee
~/bash/dirstack/eee ~/bash/dirstack/ddd ~/bash/dirstack/ccc ~/bash/dirstack/bbb ~/bash/dirstack/aaa ~/bash/dirstack
[zorro@zorrozou-pc0 eee]$ dirs
~/bash/dirstack/eee ~/bash/dirstack/ddd ~/bash/dirstack/ccc ~/bash/dirstack/bbb ~/bash/dirstack/aaa ~/bash/dirstack

将当前工作目录切换到目录栈中的第2个目录,即当前的ddd目录:

[zorro@zorrozou-pc0 eee]$ pushd +1
~/bash/dirstack/ddd ~/bash/dirstack/ccc ~/bash/dirstack/bbb ~/bash/dirstack/aaa ~/bash/dirstack ~/bash/dirstack/eee

将当前工作目录切换到目录栈中的第5个目录,即当前的~/bash/dirstack目录:

[zorro@zorrozou-pc0 ddd]$ pushd +4
~/bash/dirstack ~/bash/dirstack/eee ~/bash/dirstack/ddd ~/bash/dirstack/ccc ~/bash/dirstack/bbb ~/bash/dirstack/aaa

+N表示当前目录栈从左往右数的第N个,第一个是左边的第一个目录,从0开始。
将当前工作目录切换到目录栈中的倒数第3个目录,即当前的ddd目录:

[zorro@zorrozou-pc0 dirstack]$ pushd -3
~/bash/dirstack/ddd ~/bash/dirstack/ccc ~/bash/dirstack/bbb ~/bash/dirstack/aaa ~/bash/dirstack ~/bash/dirstack/eee

-N表示当亲啊目录栈从右往左数的第N个,第一个是右边的第一个目录,从0开始。
从目录栈中推出一个目录,默认推出当前所在的目录:

[zorro@zorrozou-pc0 ccc]$ popd 
~/bash/dirstack/ddd ~/bash/dirstack/bbb ~/bash/dirstack/aaa ~/bash/dirstack ~/bash/dirstack/eee
[zorro@zorrozou-pc0 ddd]$ popd 
~/bash/dirstack/bbb ~/bash/dirstack/aaa ~/bash/dirstack ~/bash/dirstack/eee

指定要推出的目录编号,数字含义跟pushd一样:

[zorro@zorrozou-pc0 bbb]$ popd +2
~/bash/dirstack/bbb ~/bash/dirstack/aaa ~/bash/dirstack/eee
[zorro@zorrozou-pc0 bbb]$ popd -2
~/bash/dirstack/aaa ~/bash/dirstack/eee
[zorro@zorrozou-pc0 aaa]$ pushd +1
~/bash/dirstack/eee ~/bash/dirstack/aaa

readonly

声明一个只读变量。

local

声明一个局部变量。bash的局部变量概念很简单,它只能在函数中使用,并且局部变量只有在函数中可见。

set

shopt

我们之前已经讲过这两个命令的使用。这里补充一下其他信息,请参见:http://www.cnblogs.com/ziyunfei/p/4913758.html

eval

eval是一个可能会被经常用到的内建命令。它的作用其实很简单,就是将指定的命令解析两次。可以这样理解这个命令:

首先我们定义一个变量:

[zorro@zorrozou-pc0 bash]$ pipe="|"
[zorro@zorrozou-pc0 bash]$ echo $pipe
|

这个变量时pipe,值就是”|”这个字符。然后我们试图在后续命令中引入管道这个功能,但是管道符是从变量中引入的,如:

[zorro@zorrozou-pc0 bash]$ cat /etc/passwd $pipe wc -l
cat: invalid option -- 'l'
Try 'cat --help' for more information.

此时执行报错了,因为bash在解释这条命令的时候,并不会先将$pipe解析成”|”再做解释。这时候我们需要让bash先解析$pipe,然后得到”|”字符之后,再将cat /etc/passwd | wc -l当成一个要执行的命令传给bash解释执行。此时我们需要eval:

[zorro@zorrozou-pc0 bash]$ eval cat /etc/passwd $pipe wc -l
30

这就是eval的用法。再来理解一下,eval就是将所给的命令解析两遍

最后

通过本文和之前的文章,我们几乎将所有的bash内建命令都覆盖到了。本文主要包括的知识点为:

  1. bash脚本程序的输入输出。
  2. bash的作业控制。
  3. bash脚本的信号处理。
  4. bash对进程的控制。
  5. 命令行参数处理。
  6. 使用内建命令改变bash相关环境。

希望这些内容对大家进一步深入了解bash编程有帮助。如果有相关问题,可以在我的微博、微信或者博客上联系我。

如果本文有用,请刷下面二维码捐赠0.99元。穷佐罗将持续为您奉上高质量的技术文章。多谢支持!

mm_facetoface_collect_qrcode_1465221734716


大家好,我是Zorro!

如果你喜欢本文,欢迎在微博上搜索“orroz”关注我,地址是:http://weibo.com/orroz

大家也可以在微信上搜索:Linux系统技术 关注我的公众号。

我的所有文章都会沉淀在我的个人博客上,地址是:http://liwei.life。

欢迎使用以上各种方式一起探讨学习,共同进步。

公众号二维码:

Zorro] icon


 

SHELL编程之特殊符号

SHELL编程之特殊符号

版权声明:

本文章内容在非商业使用前提下可无需授权任意转载、发布。

转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:**orroz**

微信公众号:**Linux系统技术**

前言

本文是shell编程系列的第四篇,集中介绍了bash编程可能涉及到的特殊符号的使用。学会本文内容可以帮助你写出天书一样的bash脚本,并且顺便解决以下问题:

  1. 输入输出重定向是什么原理?
  2. exec 3<> /tmp/filename是什么鬼?
  3. 你玩过bash的关联数组吗?
  4. 如何不用if判断变量是否被定义?
  5. 脚本中字符串替换和删除操作不用sed怎么做?
  6. ” “和’ ‘有什么不同?
  7. 正则表达式和bash通配符是一回事么?

这里需要额外注意的是,相同的符号出现在不同的上下文中可能会有不同的含义。我们会在后续的讲解中突出它们的区别。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

重定向(REDIRECTION)

重定向也叫输入输出重定向。我们先通过基本的使用对这个概念有个感性认识。

输入重定向

大家应该都用过cat命令,可以输出一个文件的内容。如:cat /etc/passwd。如果不给cat任何参数,那么cat将从键盘(标准输入)读取用户的输入,直接将内容显示到屏幕上,就像这样:

[zorro@zorrozou-pc0 bash]$ cat
hello 
hello
I am zorro!
I am zorro!

可以通过输入重定向让cat命令从别的地方读取输入,显示到当前屏幕上。最简单的方式是输入重定向一个文件,不过这不够“神奇”,我们让cat从别的终端读取输入试试。我当前使用桌面的终端terminal开了多个bash,使用ps命令可以看到这些终端所占用的输入文件是哪个:

[zorro@zorrozou-pc0 bash]$ ps ax|grep bash
 4632 pts/0    Ss     0:00 -bash
 5087 pts/2    S+     0:00 man bash
 5897 pts/1    Ss     0:00 -bash
 5911 pts/2    Ss     0:00 -bash
 9071 pts/4    Ss     0:00 -bash
11667 pts/3    Ss+    0:00 -bash
16309 pts/4    S+     0:00 grep --color=auto bash
19465 pts/2    S      0:00 sudo bash
19466 pts/2    S      0:00 bash

通过第二列可以看到,不同的bash所在的终端文件是哪个,这里的pts/3就意味着这个文件放在/dev/pts/3。我们来试一下,在pts/2对应的bash中输入:

[zorro@zorrozou-pc0 bash]$ cat < /dev/pts/3 

然后切换到pts/3所在的bash上敲入字符串,在pts/2的bash中能看见相关字符:

[zorro@zorrozou-pc0 bash]$ cat < /dev/pts/3 
safsdfsfsfadsdsasdfsafadsadfd

这只是个输入重定向的例子,一般我们也可以直接cat < /etc/passwd,表示让cat命令不是从默认输入读取,而是从/etc/passwd读取,这就是输入重定向,使用”<“。

输出重定向

绝大多数命令都有输出,用来显示给人看,所以输出基本都显示在屏幕(终端)上。有时候我们不想看到,就可以把输出重定向到别的地方:

[zorro@zorrozou-pc0 bash]$ ls /
bin  boot  cgroup  data  dev  etc  home  lib  lib64  lost+found  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
[zorro@zorrozou-pc0 bash]$ ls / > /tmp/out
[zorro@zorrozou-pc0 bash]$ cat /tmp/out
bin
boot
cgroup
data
dev
......

使用一个”>”,将原本显示在屏幕上的内容给输出到了/tmp/out文件中。这个功能就是输出重定向。

报错重定向

命令执行都会遇到错误,一般也都是给人看的,所以默认还是显示在屏幕上。这些输出使用”>”是不能进行重定向的:

[zorro@zorrozou-pc0 bash]$ ls /1234 > /tmp/err
ls: cannot access '/1234': No such file or directory

可以看到,报错还是显示在了屏幕上。如果想要重定向这样的内容,可以使用”2>”:

[zorro@zorrozou-pc0 bash]$ ls /1234 2> /tmp/err
[zorro@zorrozou-pc0 bash]$ cat /tmp/err
ls: cannot access '/1234': No such file or directory

以上就是常见的输入输出重定向。在进行其它技巧讲解之前,我们有必要理解一下重定向的本质,所以要先从文件描述符说起。

文件描述符(file descriptor)

文件描述符简称fd,它是一个抽象概念,在很多其它体系下,它可能有其它名字,比如在C库编程中可以叫做文件流或文件流指针,在其它语言中也可以叫做文件句柄(handler),而且这些不同名词的隐含意义可能是不完全相同的。不过在系统层,还是应该使用系统调用中规定的名词,我们统一把它叫做文件描述符。

文件描述符本质上是一个数组下标(C语言数组)。在内核中,这个数组是用来管理一个进程打开的文件的对应关系的数组。就是说,对于任何一个进程来说,都有这样一个数组来管理它打开的文件,数组中的每一个元素和文件是映射关系,即:一个数组元素只能映射一个文件,而一个文件可以被多个数组元素所映射。

其实上面的描述并不完全准确,在内核中,文件描述符的数组所直接映射的实际上是文件表,文件表再索引到相关文件的v_node。具体可以参见《UNIX系统高级编程》。

shell在产生一个新进程后,新进程的前三个文件描述符都默认指向三个相关文件。这三个文件描述符对应的数组下标分别为0,1,2。0对应的文件叫做标准输入(stdin),1对应的文件叫做标准输出(stdout),2对应的文件叫做标准报错(stderr)。但是实际上,默认跟人交互的输入是键盘、鼠标,输出是显示器屏幕,这些硬件设备对于程序来说都是不认识的,所以操作系统借用了原来“终端”的概念,将键盘鼠标显示器都表现成一个终端文件。于是stdin、stdout和stderr就最重都指向了这所谓的终端文件上。于是,从键盘输入的内容,进程可以从标准输入的0号文件描述符读取,正常的输出内容从1号描述符写出,报错信息被定义为从2号描述符写出。这就是标准输入、标准输出和标准报错对应的描述符编号是0、1、2的原因。这也是为什么对报错进行重定向要使用2>的原因(其实1>也是可以用的)。

明白了以上内容之后,很多重定向的数字魔法就好理解了,比如:

[zorro@zorrozou-pc0 prime]$ find /etc -name passwd > /dev/null 
find: ‘/etc/docker’: Permission denied
find: ‘/etc/sudoers.d’: Permission denied
find: ‘/etc/lvm/cache’: Permission denied
find: ‘/etc/pacman.d/gnupg/openpgp-revocs.d’: Permission denied
find: ‘/etc/pacman.d/gnupg/private-keys-v1.d’: Permission denied
find: ‘/etc/polkit-1/rules.d’: Permission denied

这相当于只看报错信息。

[zorro@zorrozou-pc0 prime]$ find /etc -name passwd 2> /dev/null 
/etc/default/passwd
/etc/pam.d/passwd
/etc/passwd

这相当于只看正确输出信息。

[zorro@zorrozou-pc0 prime]$ find /etc -name passwd &> /dev/null

所有输出都不看,也可以写成”>&”。

[zorro@zorrozou-pc0 prime]$ find /etc -name passwd 2>&1
/etc/default/passwd
find: ‘/etc/docker’: Permission denied
/etc/pam.d/passwd
find: ‘/etc/sudoers.d’: Permission denied
find: ‘/etc/lvm/cache’: Permission denied
find: ‘/etc/pacman.d/gnupg/openpgp-revocs.d’: Permission denied
find: ‘/etc/pacman.d/gnupg/private-keys-v1.d’: Permission denied
find: ‘/etc/polkit-1/rules.d’: Permission denied
/etc/passwd

将标准报错输出的,重定向到标准输出再输出。

[zorro@zorrozou-pc0 prime]$ echo hello > /tmp/out 
[zorro@zorrozou-pc0 prime]$ cat /tmp/out
hello
[zorro@zorrozou-pc0 prime]$ echo hello2 >> /tmp/out 
[zorro@zorrozou-pc0 prime]$ cat /tmp/out
hello
hello2

“>>”表示追加重定向。

相信大家对&>>、1>&2、?2>&3、6>&8、>>file 2>&1这样的写法应该也都能理解了。进程可以打开多个文件,多个描述符之间都可以进行重定向。当然,输入也可以,比如:3<表示从描述符3读取。下面我们罗列一下其他重定向符号和用法:

Here Document

语法:

<<[-]word
    here-document
delimiter

这是一种特殊的输入重定向,重定向的内容并不是来自于某个文件,而是从当前输入读取,直到输入中写入了delimiter字符标记结束。用法:

[zorro@zorrozou-pc0 prime]$ cat << EOF
> hello world!
> I am zorro
> 
> 
> 
> sadfsdf
> ertert
> eof
> EOF
hello world!
I am zorro



sadfsdf
ertert
eof

这个例子可以看到,最后cat输出的内容都是在上面写入的内容,而且内容中不包括EOF,因为EOF是标记输入结束的字符串。这个功能在脚本中通常可以用于需要交互式处理的某些命令的输入和文件编辑,比如想在脚本中使用fdisk命令新建一个分区:

[root@zorrozou-pc0 prime]# cat fdisk.sh 
#!/bin/bash

fdisk /dev/sdb << EOF
n
p


w
EOF

当然这个脚本大家千万不要乱执行,可能会修改你的分区表。其中要输入的内容,相信熟悉fdisk命令的人应该都能明白,我就不多解释了。

Here strings

语法:

<<<word

使用方式:

[zorro@zorrozou-pc0 prime]$ cat <<< asdasdasd
asdasdasd

其实就是将<<<符号后面的字符串当成要输入的内容给cat,而不是定向一个文件描述符。这样是不是就相当于把cat当echo用了?

文件描述符的复制

复制输入文件描述符:[n]<&word

如果n没有指定数字,则默认复制0号文件描述符。word一般写一个已经打开的并且用来作为输入的描述符数字,表示将制订的n号描述符在制定的描述符上复制一个。如果word写的是“-”符号,则表示关闭这个文件描述符。如果word指定的不是一个用来输入的文件描述符,则会报错。

复制输出文件描述符:[n]>&word

复制一个输出的描述符,字段描述参考上面的输入复制,例子上面已经讲过了。这里还需要知道的就是1>&-表示关闭1号描述符。

文件描述符的移动

移动输入描述符:[n]<&digit-

移动输出描述符:[n]>&digit-

这两个符号的意思都是将原有描述符在新的描述符编号上打开,并且关闭原有描述符。

描述符新建

新建一个用来输入的描述符:[n]<word

新建一个用来输出的描述符:[n]>word

新建一个用来输入和输出的描述符:[n]<>word

word都应该写一个文件路径,用来表示这个文件描述符的关联文件是谁。

下面我们来看相关的编程例子:

#!/bin/bash

# example 1
#打开3号fd用来输入,关联文件为/etc/passwd
exec 3< /etc/passwd
#让3号描述符成为标准输入
exec 0<&3
#此时cat的输入将是/etc/passwd,会在屏幕上显示出/etc/passwd的内容。
cat

#关闭3号描述符。
exec 3>&-

# example 2
#打开3号和4号描述符作为输出,并且分别关联文件。
exec 3> /tmp/stdout

exec 4> /tmp/stderr

#将标准输入关联到3号描述符,关闭原来的1号fd。
exec 1>&3-
#将标准报错关联到4号描述符,关闭原来的2号fd。
exec 2>&4-

#这个find命令的所有正常输出都会写到/tmp/stdout文件中,错误输出都会写到/tmp/stderr文件中。
find /etc/ -name "passwd"

#关闭两个描述符。
exec 3>&-
exec 4>&-

以上脚本要注意的地方是,一般输入输出重定向都是放到命令后面作为后缀使用,所以如果单纯改变脚本的描述符,需要在前面加exec命令。这种用法也叫做描述符魔术。某些特殊符号还有一些特殊用法,比如:

zorro@zorrozou-pc0 bash]$ > /tmp/out

表示清空文件,当然也可以写成:

[zorro@zorrozou-pc0 bash]$ :> /tmp/out

因为”:”是一个内建命令,跟true是同样的功能,所以没有任何输出,所以这个命令清空文件的作用。

脚本参数处理

我们在之前的例子中已经简单看过相关参数处理的特殊符号了,再来看一下:

[zorro@zorrozou-pc0 bash]$ cat arg1.sh 
#!/bin/bash

echo $0
echo $1
echo $2
echo $3
echo $4
echo $#
echo $*
echo $?

执行结果:

[zorro@zorrozou-pc0 bash]$ ./arg1.sh 111 222 333 444
./arg1.sh
111
222
333
444
4
111 222 333 444
0

可以罗列一下:

$0:命令名。

$n:n是一个数字,表示第n个参数。

$#:参数个数。

$*:所有参数列表。

$@:同上。

实际上大家可以认为上面的0,1,2,3,#,*,@,?都是一堆变量名。跟aaa=1000定义的变量没什么区别,只是他们有特殊含义。所以$@实际上就是对@变量取值,跟$aaa概念一样。所以上述所有取值都可以写成${}的方式,因为bash中对变量取值有两种写法,另外一种是${aaa}。这种写法的好处是对变量名字可以有更明确的界定,比如:

[zorro@zorrozou-pc0 bash]$ aaa=1000
[zorro@zorrozou-pc0 bash]$ echo $aaa
1000
[zorro@zorrozou-pc0 bash]$ echo $aaa0

[zorro@zorrozou-pc0 bash]$ echo ${aaa}0
10000

内建命令shift可以用来对参数进行位置处理,它会将所有参数都左移一个位置,可以用来进行参数处理。使用例子如下:

[zorro@zorrozou-pc0 ~]$ cat shift.sh
#!/bin/bash

if [ $# -lt 1 ]
then
    echo "Argument num error!" 1>&2
    echo "Usage ....." 1>&2
    exit
fi

while ! [ -z $1 ]
do
    echo $1
    shift
done

执行效果:

[zorro@zorrozou-pc0 bash]$ ./shift.sh 111 222 333 444 555 666
111
222
333
444
555
666

其他的特殊变量还有:

$?:上一个命令的返回值。

$$:当前shell的PID。

$!:最近一个被放到后台任务管理的进程PID。如:

[zorro@zorrozou-pc0 tmp]$ sleep 3000 &
[1] 867
[zorro@zorrozou-pc0 tmp]$ echo $!
867

$-:列出当前bash的运行参数,比如set -v或者-i这样的参数。

$:”“算是所有特殊变量中最诡异的一个了,在bash脚本刚开始的时候,它可以取到脚本的完整文件名。当执行完某个命令之后,它可以取到,这个命令的最后一个参数。当在检查邮件的时候,这个变量帮你保存当前正在查看的邮件名。

数组操作

bash中可以定义数组,使用方法如下:

[zorro@zorrozou-pc0 bash]$ cat array.sh
#!/bin/bash
#定义一个一般数组
declare -a array

#为数组元素赋值
array[0]=1000
array[1]=2000
array[2]=3000
array[3]=4000

#直接使用数组名得出第一个元素的值
echo $array
#取数组所有元素的值
echo ${array[*]}
echo ${array[@]}
#取第n个元素的值
echo ${array[0]}
echo ${array[1]}
echo ${array[2]}
echo ${array[3]}
#数组元素个数
echo ${#array[*]}
#取数组所有索引列表
echo ${!array[*]}
echo ${!array[@]}

#定义一个关联数组
declare -A assoc_arr

#为关联数组复制
assoc_arr[zorro]='zorro'
assoc_arr[jerry]='jerry'
assoc_arr[tom]='tom'

#所有操作同上
echo $assoc_arr
echo ${assoc_arr[*]}
echo ${assoc_arr[@]}
echo ${assoc_arr[zorro]}
echo ${assoc_arr[jerry]}
echo ${assoc_arr[tom]}
echo ${#assoc_arr[*]}
echo ${!assoc_arr[*]}
echo ${!assoc_arr[@]}

命令行扩展

大括号扩展

用类似枚举的方式创建一些目录:

[zorro@zorrozou-pc0 bash]$ mkdir -p test/zorro/{a,b,c,d}{1,2,3,4}
[zorro@zorrozou-pc0 bash]$ ls test/zorro/
a1  a2  a3  a4  b1  b2  b3  b4  c1  c2  c3  c4  d1  d2  d3  d4

可能还有这样用的:

[zorro@zorrozou-pc0 bash]$ mv test/{a,c}.conf

这个命令的意思是:mv test/a.conf test/c.conf

~符号扩展

:在bash中一般表示用户的主目录。cd ~表示回到主目录。cd ~zorro表示回到zorro用户的主目录。

变量扩展

我们都知道取一个变量值可以用$或者${}。在使用${}的时候可以添加很多对变量进行扩展操作的功能,下面我们就分别来看看。

${aaa:-1000}

这个表示如果变量aaa是空值或者没有赋值,则此表达式取值为1000,aaa变量不被更改,以后还是空。如果aaa已经被赋值,则原值不变:

[zorro@zorrozou-pc0 bash]$ echo $aaa

[zorro@zorrozou-pc0 bash]$ echo ${aaa:-1000}
1000
[zorro@zorrozou-pc0 bash]$ echo $aaa
[zorro@zorrozou-pc0 bash]$ aaa=2000
[zorro@zorrozou-pc0 bash]$ echo $aaa
2000
[zorro@zorrozou-pc0 bash]$ echo ${aaa:-1000}
2000
[zorro@zorrozou-pc0 bash]$ echo $aaa
2000

${aaa:=1000}

跟上面的表达式的区别是,如果aaa未被赋值,则赋值成=后面的值,其他行为不变:

[zorro@zorrozou-pc0 bash]$ echo $aaa

[zorro@zorrozou-pc0 bash]$ echo ${aaa:=1000}
1000
[zorro@zorrozou-pc0 bash]$ echo $aaa
1000

${aaa:?unset}

判断变量是否为定义或为空,如果符合条件,就提示?后面的字符串。

[zorro@zorrozou-pc0 bash]$ echo ${aaa:?unset}
-bash: aaa: unset
[zorro@zorrozou-pc0 bash]$ aaa=1000
[zorro@zorrozou-pc0 bash]$ echo ${aaa:?unset}
1000

${aaa:?unset}

如果aaa为空或者未设置,则什么也不做。如果已被设置,则取?后面的值。并不改变原aaa值:

[zorro@zorrozou-pc0 bash]$ aaa=1000
[zorro@zorrozou-pc0 bash]$ echo ${aaa:+unset}
unset
[zorro@zorrozou-pc0 bash]$ echo $aaa
1000

${aaa:10}

取字符串偏移量,表示取出aaa变量对应字符串的第10个字符之后的字符串,变量原值不变。

[zorro@zorrozou-pc0 bash]$ aaa='/home/zorro/zorro.txt'
[zorro@zorrozou-pc0 bash]$ echo ${aaa:10}
o/zorro.txt

${aaa:10:15}

第二个数字表示取多长:

[zorro@zorrozou-pc0 bash]$ echo ${aaa:10:5}
o/zor

${!B*}

${!B@}

取出所有以B开头的变量名(请注意他们跟数组中相关符号的差别):

[zorro@zorrozou-pc0 bash]$ echo ${!B*}
BASH BASHOPTS BASHPID BASH_ALIASES BASH_ARGC BASH_ARGV BASH_CMDS BASH_COMMAND BASH_LINENO BASH_SOURCE BASH_SUBSHELL BASH_VERSINFO BASH_VERSION

${#aaa}

取变量长度:

[zorro@zorrozou-pc0 bash]$ echo ${#aaa}
21

${parameter#word}

变量paramenter看做字符串从左往右找到第一个word,取其后面的字串:

[zorro@zorrozou-pc0 bash]$ echo ${aaa#/}
home/zorro/zorro.txt

这里需要注意的是,word必须是一个路径匹配的字符串,比如:

[zorro@zorrozou-pc0 bash]$ echo ${aaa#*zorro}
/zorro.txt

这个表示删除路径中匹配到的第一个zorro左边的所有字符,而这样是无效的:

[zorro@zorrozou-pc0 bash]$ echo ${aaa#zorro}
/home/zorro/zorro.txt

因为此时zorro不是一个路径匹配。另外,这个表达式只能删除匹配到的左边的字符串,保留右边的。

${parameter##word}

这个表达式与上一个的区别是,匹配的不是第一个符合条件的word,而是最后一个:

[zorro@zorrozou-pc0 bash]$ echo ${aaa##*zorro}
.txt
[zorro@zorrozou-pc0 bash]$ echo ${aaa##*/}
zorro.txt

${parameter%word}
${parameter%%word}

这两个符号相对于上面两个相当于#号换成了%号,操作区别也从匹配删除左边的字符变成了匹配删除右边的字符,如:

[zorro@zorrozou-pc0 bash]$ echo ${aaa%/*}
/home/zorro
[zorro@zorrozou-pc0 bash]$ echo ${aaa%t}
/home/zorro/zorro.tx
[zorro@zorrozou-pc0 bash]$ echo ${aaa%.*}
/home/zorro/zorro
[zorro@zorrozou-pc0 bash]$ echo ${aaa%%z*}
/home/

以上#号和%号分别是匹配删除哪边的,容易记不住。不过有个窍门是,可以看看他们分别在键盘上的$的哪边?在左边的就是匹配删除左边的,在右边就是匹配删除右边的。

${parameter/pattern/string}

字符串替换,将pattern匹配到的第一个字符串替换成string,pattern可以使用通配符,如:

[zorro@zorrozou-pc0 bash]$ echo $aaa
/home/zorro/zorro.txt
[zorro@zorrozou-pc0 bash]$ echo ${aaa/zorro/jerry}
/home/jerry/zorro.txt
[zorro@zorrozou-pc0 bash]$ echo ${aaa/zorr?/jerry}
/home/jerry/zorro.txt
[zorro@zorrozou-pc0 bash]$ echo ${aaa/zorr*/jerry}
/home/jerry

${parameter//pattern/string}

意义同上,不过变成了全局替换:

[zorro@zorrozou-pc0 bash]$ echo ${aaa//zorro/jerry}
/home/jerry/jerry.txt

${parameter^pattern}
${parameter^^pattern}
${parameter,pattern}
${parameter,,pattern}

大小写转换,如:

[zorro@zorrozou-pc0 bash]$ echo $aaa
abcdefg
[zorro@zorrozou-pc0 bash]$ echo ${aaa^}
Abcdefg
[zorro@zorrozou-pc0 bash]$ echo ${aaa^^}
ABCDEFG
[zorro@zorrozou-pc0 bash]$ aaa=ABCDEFG
[zorro@zorrozou-pc0 bash]$ echo ${aaa,}
aBCDEFG
[zorro@zorrozou-pc0 bash]$ echo ${aaa,,}
abcdefg

有了以上符号后,很多变量内容的处理就不必再使用sed这样的重型外部命令处理了,可以一定程度的提高bash脚本的执行效率。

命令置换

命令置换这个概念就是在命令行中引用一个命令的输出给bash执行,就是我们已经用过的符号,如:
<pre><code>[zorro@zorrozou-pc0 bash]$ echo ls
ls
[zorro@zorrozou-pc0 bash]$ `echo ls`
3 arg1.sh array.sh auth_if.sh cat.sh for2.sh hash.sh name.sh ping.sh redirect.sh shift.sh until.sh
alias.sh arg.sh auth_case.sh case.sh exit.sh for.sh if_1.sh na.sh prime select.sh test while.sh
</code></pre>
bash会执行放在
号中的命令,并将其输出作为bash的命令再执行一遍。在某些情况下双反引号的表达能力有欠缺,比如嵌套的时候就分不清到底是谁嵌套谁?所以bash还提供另一种写法,跟这个符号一样就是$()。

算数扩展

$(())

$[]

绝大多数算是表达式可以放在$(())和$[]中进行取值,如:

[zorro@zorrozou-pc0 bash]$ echo $((123+345))
468
[zorro@zorrozou-pc0 bash]$ 
[zorro@zorrozou-pc0 bash]$ 
[zorro@zorrozou-pc0 bash]$ echo $((345-123))
222
[zorro@zorrozou-pc0 bash]$ echo $((345*123))
42435
[zorro@zorrozou-pc0 bash]$ echo $((345/123))
2
[zorro@zorrozou-pc0 bash]$ echo $((345%123))
99
[zorro@zorrozou-pc0 bash]$ i=1
[zorro@zorrozou-pc0 bash]$ echo $((i++))
1
[zorro@zorrozou-pc0 bash]$ echo $((i++))
2
[zorro@zorrozou-pc0 bash]$ echo $i
3
[zorro@zorrozou-pc0 bash]$ i=1
[zorro@zorrozou-pc0 bash]$ echo $((++i))
2
[zorro@zorrozou-pc0 bash]$ echo $((++i))
3
[zorro@zorrozou-pc0 bash]$ echo $i
3

可以支持的运算符包括:

   id++ id--

   ++id --id
   - +    
   ! ~    
   **     
   * / %  
   + -    
   << >>  
   <= >= < >

   == !=  
   &     
   ^  
   |    
   &&    
   ||   
   expr?expr:expr
   = *= /= %= += -= <<= >>= &= ^= |=

另外可以进行算数运算的还有内建命令let:

[zorro@zorrozou-pc0 bash]$ i=0
[zorro@zorrozou-pc0 bash]$ let ++i
[zorro@zorrozou-pc0 bash]$ echo $i
1
[zorro@zorrozou-pc0 bash]$ i=2
[zorro@zorrozou-pc0 bash]$ let i=i**2
[zorro@zorrozou-pc0 bash]$ echo $i
4

let的另外一种写法是(()):

[zorro@zorrozou-pc0 bash]$ i=0
[zorro@zorrozou-pc0 bash]$ ((i++))
[zorro@zorrozou-pc0 bash]$ echo $i
1
[zorro@zorrozou-pc0 bash]$ ((i+=4))
[zorro@zorrozou-pc0 bash]$ echo $i
5
[zorro@zorrozou-pc0 bash]$ ((i=i**7))
[zorro@zorrozou-pc0 bash]$ echo $i
78125

进程置换

<(list) 和 >(list)

这两个符号可以将list的执行结果当成别的命令需要输入或者输出的文件进行操作,比如我想比较两个命令执行结果的区别:

[zorro@zorrozou-pc0 bash]$ diff <(df -h) <(df)
1,10c1,10
< Filesystem               Size  Used Avail Use% Mounted on
< dev                      7.8G     0  7.8G   0% /dev
< run                      7.9G  1.1M  7.8G   1% /run
< /dev/sda3                 27G   13G   13G  50% /
< tmpfs                    7.9G  500K  7.8G   1% /dev/shm
< tmpfs                    7.9G     0  7.9G   0% /sys/fs/cgroup
< tmpfs                    7.9G  112K  7.8G   1% /tmp
< /dev/mapper/fedora-home   99G   76G   18G  82% /home
< tmpfs                    1.6G   16K  1.6G   1% /run/user/120
< tmpfs                    1.6G   16K  1.6G   1% /run/user/1000
---
> Filesystem              1K-blocks     Used Available Use% Mounted on
> dev                       8176372        0   8176372   0% /dev
> run                       8178968     1052   8177916   1% /run
> /dev/sda3                28071076 13202040  13420028  50% /
> tmpfs                     8178968      500   8178468   1% /dev/shm
> tmpfs                     8178968        0   8178968   0% /sys/fs/cgroup
> tmpfs                     8178968      112   8178856   1% /tmp
> /dev/mapper/fedora-home 103081248 79381728  18440256  82% /home
> tmpfs                     1635796       16   1635780   1% /run/user/120
> tmpfs                     1635796       16   1635780   1% /run/user/1000

这个符号会将相关命令的输出放到/dev/fd中创建的一个管道文件中,并将管道文件作为参数传递给相关命令进行处理。

路径匹配扩展

我们已经知道了路径文件名匹配中的*、?、[abc]这样的符号。bash还给我们提供了一些扩展功能的匹配,需要先使用内建命令shopt打开功能开关。支持的功能有:

?(pattern-list):匹配所给pattern的0次或1次;
*(pattern-list):匹配所给pattern的0次以上包括0次;
+(pattern-list):匹配所给pattern的1次以上包括1次;
@(pattern-list):匹配所给pattern的1次;
!(pattern-list):匹配非括号内的所给pattern。

使用:

[zorro@zorrozou-pc0 bash]$ shopt -u extglob
[zorro@zorrozou-pc0 bash]$ ls /etc/*(*a)
/etc/netdata:
apps_groups.conf  charts.d.conf  netdata.conf

/etc/pcmcia:
config.opts

关闭功能之后不能使用:

[zorro@zorrozou-pc0 bash]$ shopt -u extglob
[zorro@zorrozou-pc0 bash]$ ls /etc/*(*a)
-bash: syntax error near unexpected token `('

其他常用符号

关键字或保留字是一类特殊符号或者单词,它们具有相同的实现属性,即:使用type命令查看其类型都显示key word。

[zorro@zorrozou-pc0 bash]$ type !
! is a shell keyword

!:当只出现一个叹号的时候代表对表达式(命令的返回值)取非。如:

[zorro@zorrozou-pc0 bash]$ echo hello
hello
[zorro@zorrozou-pc0 bash]$ echo $?
0
[zorro@zorrozou-pc0 bash]$ ! echo hello
hello
[zorro@zorrozou-pc0 bash]$ echo $?
1

[[]]:这个符号基本跟内建命令test一样,当然我们也知道,内建命令test的另一种写法是[ ]。使用:

[root@zorrozou-pc0 zorro]# [[ -f /etc/passwd ]]
[root@zorrozou-pc0 zorro]# echo $?
0
[root@zorrozou-pc0 zorro]# [[ -f /etc/pass ]]
[root@zorrozou-pc0 zorro]# echo $?
1

可以支持的判断参数可以help test查看。

管道”|”或|&:管道其实有两种写法,但是我们一般只常用其中单竖线一种。使用的语法格式:

command1 [ [|⎪|&] command2 ... ]

管道“|”的主要作用是将command1的标准输出跟command2的标准输入通过管道(pipe)连接起来。“|&”这种写法的含义是将command1标准输出和标准报错都跟command2的和准输入连接起来,这相当于是command1 2>&1 | command2的简写方式。

&&:用逻辑与关系连接两个命令,如:command1 && command2,表示当command1执行成功才执行command2,否则command2不会执行。

||:用逻辑或关系连接两个命令,如:command1 || command2,表示当command1执行不成功才执行command2,否则command2不会执行。

有了这两个符号,很多if判断都不用写了。

&:一般作为一个命令或者lists的后缀,表明这个命令的执放到jobs中跑,bash不必wait进程。

;:作为命令或者lists的后缀,主要起到分隔多个命令用的,效果跟回车是一样的。

(list):放在()中执行的命令将在一个subshell环境中执行,这样的命令将打开一个bash子进程执行。即使要执行的是内建命令,也要打开一个subshell的子进程。另外要注意的是,当内建命令前后有管道符号连接的时候,内建命令本身也是要放在subshell中执行的。这个subshell子进程的执行环境基本上是父进程的复制,除了重置了信号的相关设置。bash编程的信号设置使用内建命令trap,将在后续文章中详细说明。

{ list; }:大括号作为函数语法结构中的标记字段和list标记字段,是一个关键字。在大括号中要执行的命令列表(list)会放在当前执行环境中执行。命令列表必须以一个换行或者分号作为标记结束。

转义字符

转义字符很重要,所以需要单独拿出来重点说一下。既然bash给我们提供了这么多的特殊字符,那么这些字符对于bash来说就是需要进行特殊处理的。比如我们想创建一个文件名中包含*的文件:

[zorro@zorrozou-pc0 bash]$ ls
3         arg1.sh  array.sh      auth_if.sh  cat.sh   for2.sh  hash.sh  name.sh  ping.sh  read.sh      select.sh  test      while.sh
alias.sh  arg.sh   auth_case.sh  case.sh     exit.sh  for.sh   if_1.sh  na.sh    prime    redirect.sh  shift.sh   until.sh
[zorro@zorrozou-pc0 bash]$ touch *sh

这个命令会被bash转义成,对所有文件名以sh结尾的文件做touch操作。那究竟怎么创建这个文件呢?使用转义符:

[zorro@zorrozou-pc0 bash]$ touch \*sh
[zorro@zorrozou-pc0 bash]$ ls
3         arg1.sh  array.sh      auth_if.sh  cat.sh   for2.sh  hash.sh  name.sh  ping.sh  read.sh      select.sh  shift.sh  until.sh
alias.sh  arg.sh   auth_case.sh  case.sh     exit.sh  for.sh   if_1.sh  na.sh    prime    redirect.sh  '*sh'      test      while.sh

创建了一个叫做*sh的文件,\就是转义符,它可以转义后面的一个字符。如果我想创建一个名字叫\的文件,就应该:

[zorro@zorrozou-pc0 bash]$ touch \\
[zorro@zorrozou-pc0 bash]$ ls
'\'  alias.sh  arg.sh    auth_case.sh  case.sh  exit.sh  for.sh   if_1.sh  na.sh    prime    redirect.sh  '*sh'     test      while.sh
3    arg1.sh   array.sh  auth_if.sh    cat.sh   for2.sh  hash.sh  name.sh  ping.sh  read.sh  select.sh    shift.sh  until.sh

如何删除sh呢?rm sh?注意到了么?一不小心就会误操作!正确的做法是:

[zorro@zorrozou-pc0 bash]$ rm \*sh

可以成功避免这种误操作的习惯是,不要用特殊字符作为文件名或者目录名,不要给自己犯错误的机会!

另外”也是非常重要的转义字符,\只能转义其后面的一个字符,而”可以转义其扩起来的所有字符。另外””也能起到一部分的转义作用,只是它的转义能力没有”强。”和
“”的区别是:”可以转义所有字符,而””不能对$字符、命令置换“和\转义字符进行转义。

最后

先补充一个关于正则表达式的说明:

很多初学者容易将bash的特殊字符和正则表达式搞混,尤其是*、?、[]这些符号。实际上我们要明白,正则表达式跟bash的通配符和特殊符号没有任何关系。bash本身并不支持正则表达式。那些支持正在表达式的都是外部命令,比如grep、sed、awk这些高级文件处理命令。正则表达式是由这些命令自行处理的,而bash并不对正则表达式做任何解析和解释。

关于正则表达式的话题,我们就不在bash编程系列文章中讲解了,不过未来可能会在讲解sed、awk这样的高级文本处理命令中说明。

通过本文我们学习了bash的特殊符号相关内容,主要包括的知识点为:

  1. 输入输出重定向以及描述符魔术。
  2. bash脚本的命令行参数处理。
  3. bash脚本的数组和关联数组。
  4. bash的各种其他扩展特殊字符操作。
  5. 转义字符介绍。
  6. 正则表达式和bash特殊字符的区别。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

希望这些内容对大家进一步深入了解bash编程有帮助。如果有相关问题,可以在我的微博、微信或者博客上联系我。


大家好,我是Zorro!

如果你喜欢本文,欢迎在微博上搜索“orroz”关注我,地址是:http://weibo.com/orroz

大家也可以在微信上搜索:Linux系统技术 关注我的公众号。

我的所有文章都会沉淀在我的个人博客上,地址是:http://liwei.life。

欢迎使用以上各种方式一起探讨学习,共同进步。

公众号二维码:

Zorro] icon

 

SHELL编程之执行环境

SHELL编程之执行环境

版权声明:

本文章内容在非商业使用前提下可无需授权任意转载、发布。

转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:**orroz**

微信公众号:**Linux系统技术**

前言

本文是shell编程系列的第三篇,主要介绍bash脚本的执行环境以及注意事项。通过本文,应该可以帮助您解决以下问题:

  1. 执行bash和执行sh究竟有什么区别?
  2. 如何调试bash脚本?
  3. 如何记录用户在什么时候执行的某条命令?
  4. 为什么有时ulimit命令的设置不生效或者报错?
  5. 环境变量和一般变量有什么区别??

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

 

mm_facetoface_collect_qrcode_1465221734716

常用参数

交互式login shell

关于bash的编程环境,首先我们要先理解的就是bash的参数。不同方式启动的bash一般默认的参数是不一样的。一般在命令行中使用的bash,都是以login方式打开的,对应的参数是:-l或—login。还有-i参数,表示bash是以交互方式打开的,在默认情况下,不加任何参数的bash也是交互方式打开的。这两种方式都会在启动bash之前加载一些文件:

首先,bash去检查/etc/profile文件是否存在,如果存在就读取并执行这个文件中的命令。

之后,bash再按照以下列出的文件顺序依次查看是否存在这些文件,如果任何一个文件存在,就读取、执行文件中的命令:

  1. ~/.bash_profile
  2. ~/.bash_login
  3. ~/.profile

这里要注意的是,本步骤只会检查到一个文件并处理,即使同时存在2个或3个文件,本步骤也只会处理最优先的那个文件,而忽略其他文件。以上两个步骤的检查都可以用—noprofile参数进行关闭。

当bash是以login方式登录的时候,在bash退出时(exit),会额外读取并执行~/.bash_logout文件中的命令。

当bash是以交互方式登录时(-i参数),bash会读取并执行~/.bashrc中的命令。—norc参数可以关闭这个功能,另外还可以通过—rcfile参数指定一个文件替代默认的~/.bashrc文件。

以上就是bash以login方式和交互式方式登录的主要区别,根据这个过程,我们到RHEL7的环境上看看都需要加载哪些配置:

  1. 首先是加载/etc/profile。根据RHEL7上此文件内容,这个脚本还需要去检查/etc/profile.d/目录,将里面以.sh结尾的文件都加载一遍。具体细节可以自行查看本文件内容。
  2. 之后是检查~/.bash_profile。这个文件中会加载~/.bashrc文件。
  3. 之后是处理~/.bashrc文件。此文件主要功能是给bash环境添加一些alias,之后再加载/etc/bashrc文件。
  4. 最后处理/etc/bashrc文件。这个过程并不是bash自身带的过程,而是在RHEL7系统中通过脚本调用实现。

了解了这些之后,如果你的bash环境不是在RHEL7系统上,也应该可以确定在自己环境中启动的bash到底加载了哪些配置文件。

bash和sh

几乎所有人都知道bash有个别名叫sh,也就是说在一个脚本前面写!#/bin/bash和#!/bin/sh似乎没什么不同。但是下面我们要看看它们究竟有什么不同。

首先,第一个区别就是这两个文件并不是同样的类型。如果细心观察过这两个文件的话,大家会发现:

[zorro@zorrozou-pc0 bash]$ ls -l /usr/bin/sh
lrwxrwxrwx 1 root root 4 11月 24 04:20 /usr/bin/sh -> bash
[zorro@zorrozou-pc0 bash]$ ls -l /usr/bin/bash
-rwxr-xr-x 1 root root 791304 11月 24 04:20 /usr/bin/bash

sh是指向bash的一个符号链接。符号链接就像是快捷方式,那么执行sh就是在执行bash。这说明什么?说明这两个执行方式是等同的么?实际上并不是。我们都知道在程序中是可以获得自己执行命令的进程名称的,这个方法在bash编程中可以使用$0变量来实现,参见如下脚本:

[zorro@zorrozou-pc0 bash]$ cat name.sh
#!/bin/bash

echo $0

case $0 in
*name.sh)
echo "My name is name!"
;;
*na.sh)
echo "My name is na" 
;;
*)
echo "Name error!"
;;
esac

这个脚本直接执行的结果是:

[zorro@zorrozou-pc0 bash]$ ./name.sh 
./name.sh
My name is name!

大家也能看到脚本中有个逻辑是,如果进程名字是以na.sh结尾,那么打印的内容不一样。我们如何能让同一个程序触发这段不同的逻辑呢?其实很简单,就是给这个脚本创建一个叫na.sh的符号链接:

[zorro@zorrozou-pc0 bash]$ ln -s name.sh na.sh 
[zorro@zorrozou-pc0 bash]$ ./na.sh 
./na.sh
My name is na

通过符号链接的方式改变进程名称是一种常见的编程技巧,我们可以利用这个办法让程序通过不同进程名触发不同处理逻辑。所以大家以后再遇到类似bash和sh这样的符号链接关系的进程时要格外注意它们的区别。在这里它们到底有什么区别呢?实际上bash的源代码中对以bash名称和sh名称执行的时候,会分别触发不同的逻辑,主要的逻辑区别是:以sh名称运行时,会相当于以—posix参数方式启动bash。这个方式跟一般方式的具体区别可以参见:http://tiswww.case.edu/php/chet/bash/POSIX。

我遇到过很多次因为不同文件名的处理逻辑不同而引发的问题。其中一次是因为posix模式和一般模式的ulimit -c设置不同导致的。ulimit -c参数可以设置进程出现coredump时产生的文件的大小限制。因为内存的页大多都是4k,所以一般core文件都是最小4k一个,当ulimit -c参数设置小于4k时,无法正常产生core文件。为了调试方便,我们的生产系统都开了ulimit -c限制单位为4。因为默认ulimit -c的限制单位是1k,ulimit -c 4就是4k,够用了。但是我们仍然发现部分服务器不能正常产生core文件,最后排查定位到,这些不能产生core文件的配置脚本只要将#!/bin/sh改为#!/bin/bash就可以正常产生core文件。于是郁闷之余,查阅了bash的处理代码,最终发现原来是这个坑导致的问题。原因是:在posix模式下,ulimit -c的参数单位不是1024,而是512。至于还有什么其他不同,在上述链接中都有说明。

脚本调试

程序员对程序的调试工作是必不可少的,bash本身对脚本程序提供的调试手段不多,printf大法是必要技能之一,当然在bash中就是echo大法。另外就是bash的-v参数、-x参数和-n参数。

-v参数就是可视模式,它会在执行bash程序的时候将要执行的内容也打印出来,除此之外,并不改变bash执行的过程:

[zorro@zorrozou-pc0 bash]$ cat arg.sh
#!/bin/bash -v

echo $0
echo $1
echo $2
ls /123
echo $3
echo $4

echo $#
echo $*
echo $?

执行结果是:

[zorro@zorrozou-pc0 bash]$ ./arg.sh 111 222 333 444 555
#!/bin/bash -v

echo $0
./arg.sh
echo $1
111
echo $2
222
ls /123
ls: cannot access '/123': No such file or directory
echo $3
333
echo $4
444

echo $#
5
echo $*
111 222 333 444 555
echo $?
0

-x参数是跟踪模式(xtrace)。可以跟踪各种语法的调用,并打印出每个命令的输出结果:

[zorro@zorrozou-pc0 bash]$ cat arg.sh
#!/bin/bash -x

echo $0
echo $1
echo $2
ls /123
echo $3
echo $4

echo $#
echo $*
echo $?

执行结果为:

[zorro@zorrozou-pc0 bash]$ ./arg.sh 111 222 333 444 555
+ echo ./arg.sh
./arg.sh
+ echo 111
111
+ echo 222
222
+ ls /123
ls: cannot access '/123': No such file or directory
+ echo 333
333
+ echo 444
444
+ echo 5
5
+ echo 111 222 333 444 555
111 222 333 444 555
+ echo 0
0

-n参数用来检查bash的语法错误,并且不会真正执行bash脚本。这个就不举例子了。另外,三种方式除了可以直接在bash后面加参数以外,还可以在程序中随时使用内建命令set打开和关闭,方法如下:

[zorro@zorrozou-pc0 bash]$ cat arg.sh
#!/bin/bash

set -v
#set -o verbose
echo $0
set +v
echo $1
set -x
#set -o xtrace
echo $2
ls /123
echo $3
set +x
echo $4

echo $#

set -n
#set -o noexec
echo $*
echo $?
set +n

执行结果为:

[zorro@zorrozou-pc0 bash]$ ./arg.sh 
#set -o verbose
echo $0
./arg.sh
set +v

+ echo

+ ls /123
ls: cannot access '/123': No such file or directory
+ echo

+ set +x

0

以上例子中顺便演示了1、3、#、?的意义,大家可以自行对比它们的区别以理解参数的意义。另外再补充一个-e参数,这个参数可以让bash脚本命令执行错误的时候直接退出,而不是继续执行。这个功能在某些调试的场景下非常有用!

本节只列出了几个常用的参数的意义和使用注意事项,希望可以起到抛砖引玉的作用。大家如果想要学习更多的bash参数,可以自行查看bash的man手册,并详细学习set和shopt命令的使用方法。

环境变量

我们目前已经知道有个PATH变量,bash会在查找外部命令的时候到PATH所记录的目录中进行查找,从这个例子我们可以先理解一下环境变量的作用。环境变量就类似PATH这种变量,是bash预设好的一些可能会对其状态和行为产生影响的变量。bash中实现的环境变量个数大概几十个,所有的帮助说明都可以在man bash中找到。我们还是拿一些会在bash编程中经常用到的来讲解一下。

我们可以使用env命令来查看当前bash已经定义的环境变量。set命令不加任何参数可以查看当前bash环境中的所有变量,包括环境变量和私有的一般变量。一般变量的定义方法:

[zorro@zorrozou-pc0 ~]$ aaa=1000
[zorro@zorrozou-pc0 ~]$ echo $aaa
1000
[zorro@zorrozou-pc0 ~]$ env|grep aaa
[zorro@zorrozou-pc0 ~]$ set|grep aaa
aaa=1000

上面我们定义了一个变量名字叫做aaa,我们能看到在set命令中可以显示出这个变量,但是env不显示。export命令可以将一个一般变量编程环境变量。

[zorro@zorrozou-pc0 ~]$ export aaa
[zorro@zorrozou-pc0 ~]$ env|grep aaa
aaa=1000
[zorro@zorrozou-pc0 ~]$ set|grep aaa
aaa=1000

export之后,env和set都能看到这个变量了。一般变量和环境变量的区别是:一般变量不能被子进程继承,而环境变量会被子进程继承。

[zorro@zorrozou-pc0 ~]$ env|grep aaa
aaa=1000
[zorro@zorrozou-pc0 ~]$ bbb=2000
[zorro@zorrozou-pc0 ~]$ echo $bbb
2000
[zorro@zorrozou-pc0 ~]$ echo $aaa
1000
[zorro@zorrozou-pc0 ~]$ env|grep bbb
[zorro@zorrozou-pc0 ~]$ bash
[zorro@zorrozou-pc0 ~]$ echo $aaa
1000
[zorro@zorrozou-pc0 ~]$ echo $bbb

[zorro@zorrozou-pc0 ~]$ 

上面测试中,我们的bash环境里有一个环境变量aaa=1000,又定义了一个一般变量bbb=2000。此时我们在用bash打开一个子进程,在子进程中我们发现,aaa变量仍然能取到值,但是bbb不可以。证明aaa可以被子进程继承,bbb不可以。

搞清楚了环境变量的基础知识之后,再来看一下bash中常用的环境变量:

进程自身信息相关

BASH:当前bash进程的进程名。

BASHOPTS:记录了shopt命令已经设置为打开的选项。

BASH_VERSINFO:bash的版本号信息,是一个数组。可以使用命令:echo ${BASH_VERSINFO[*]}查看数组的信息。有关数组的操作我们会在其它文章里详细说明。

BASH_VERSION:bash的版本号信息。比上一个信息更少一点。

HOSTNAME:系统主机名信息。

HOSTTYPE:系统类型信息。

OLDPWD:上一个当前工作目录。

PWD:当前工作目录。

HOME:主目录。一般指进程uid对应用户的主目录。

SHELL:bash程序所在路径。

常用数字

RANDOM:每次取这个变量的值都能得到一个0-32767的随机数。

SECONDS:当前bash已经开启了多少秒。

BASHPID:当前bash进程的PID。

EUID:进程的有效用户id。

GROUPS:进程组身份。

PPID:父进程PID。

UID:用户uid。

提示符

PS1:用户bash的交互提示符,主提示符。

PS2:第二提示符,主要用在一些除了PS1之外常见的提示符场景,比如输入了’之后回车,就能看到这个提示符。

PS3:用于select语句的交互提示符。

PS4:用于跟踪执行过程时的提示符,一般显示为”+”。比如我们在bash中使用set -x之后的跟踪提示就是这个提示符显示的。

命令历史

交互bash中提供一种方便追溯曾经使用的命令的功能,叫做命令历史功能。就是将曾经用过的命令纪录下来,以备以后查询或者重复调用。这个功能在交互方式的bash中默认打开,在bash编程环境中默认是没有开启的。可以使用set +H来关闭这个功能,set -H打开这个功能。在开启了history功能的bash中我们可以使用history内建命令查询当前的命令历史列表:

[zorro@zorrozou-pc0 bash]$ history 
1 sudo bash
2 ps ax
3 ls
4 ip ad sh

命令历史的相关配置都是通过bash的环境变量来完成的:

HISTFILE:记录命令历史的文件路径。

HISTFILESIZE:命令历史文件的行数限制

HISTCONTROL:这个变量可以用来控制命令历史的一些特性。比如一般的命令历史会完全按照我们执行命令的顺序来完整记录,如果我们连续执行相同的命令,也会重复记录,如:

[zorro@zorrozou-pc0 bash]$ pwd
/home/zorro/bash
[zorro@zorrozou-pc0 bash]$ pwd
/home/zorro/bash
[zorro@zorrozou-pc0 bash]$ pwd
/home/zorro/bash
[zorro@zorrozou-pc0 bash]$ history 
......
1173 pwd
1174 pwd
1175 pwd
1176 history 

我们可以利用这个变量的配置来消除命令历史中的重复记录:

[zorro@zorrozou-pc0 bash]$ export HISTCONTROL=ignoredups
[zorro@zorrozou-pc0 bash]$ pwd
/home/zorro/bash
[zorro@zorrozou-pc0 bash]$ pwd
/home/zorro/bash
[zorro@zorrozou-pc0 bash]$ pwd
/home/zorro/bash
[zorro@zorrozou-pc0 bash]$ history 
1177 export HISTCONTROL=ignoredups
1178 history 
1179 pwd
1180 history 

这个变量还有其它配置,ignorespace可以用来让history忽略以空格开头的命令,ignoreboth可以同时起到ignoredups和ignorespace的作用,

HISTIGNORE:可以控制history机制忽略某些命令,配置方法:

export HISTIGNORE=”pwd:ls:cd:”。

HISTSIZE:命令历史纪录的命令个数。

HISTTIMEFORMAT:可以用来定义命令历史纪录的时间格式.在命令历史中记录命令执行时间有时候很有用,配置方法:

export HISTTIMEFORMAT='%F %T '

相关时间格式的帮助可以查看man 3 strftime。

HISTCMD:当前命令历史的行数。

在交互式操作bash的时候,可以通过一些特殊符号对命令历史进行快速调用,这些符号基本都是以!开头的,除非!后面跟的是空格、换行、等号=或者小括号():

!n:表示引用命令历史中的第n条命令,如:!123,执行第123条命令。

!-n:表示引用命令历史中的倒数第n条命令,如:!-123,执行倒数第123条命令。

!!:重复执行上一条命令。

!string:在命令历史中找到最近的一条以string字符串开头的命令并执行。

!?string[?]:在命令历史中找到最近的一条包括string字符的命令并执行。如果最有一个?省略的话,就是找到以string结尾的命令。

^string1^string2^:将上一个命令中的string1字符串替换成string2字符串并执行。可以简写为:^string1^string2

!#:重复当前输入的命令。

以下符号可以作为某个命令的单词代号,如:

^:!^表示上一条命令中的第一个参数,$123^表示第123条命令的第一个参数。

$:!$表示上一条命令中的最后一个参数。!123$表示第123条命令的最后一个参数。

n(数字):!!0表示上一条命令的命令名,!!3上一条命令的第三个参数。!123:3第123条命令的第三个参数。

:表示所有参数,如:!123:\或!123*

x-y:x和y都是数字,表示从第x到第y个参数,如:!123:1-6表示第123条命令的第1个到第6个参数。只写成-y,取前y个,如:!123:-7表示0-7。

x:表示取从第x个参数之后的所有参数,相当于x-$。如:!123:2\

x-:表示取从第x个参数之后的所有参数,不包括最后一个。如:!123:2-

选择出相关命令或者参数之后,我们还可以通过一些命令对其进行操作:

h 删除所有后面的路径,只留下前面的

[zorro@zorrozou-pc0 bash]$ ls /etc/passwd
/etc/passwd
[zorro@zorrozou-pc0 bash]$ !!:h
ls /etc
...

t 删除所有前面的路径,只留下后面的

[zorro@zorrozou-pc0 bash]$ !-2:t
passwd

紧接着上面的命令执行,相当于运行passwd。

r 删除后缀.xxx, 留下文件名

[zorro@zorrozou-pc0 bash]$ ls 123.txt
ls: cannot access '123.txt': No such file or directory
[zorro@zorrozou-pc0 bash]$ !!:r
ls 123

e 删除文件名, 留下后缀

[zorro@zorrozou-pc0 bash]$ !-2:e
.txt
bash: .txt: command not found

p 只打印结果命令,但不执行

[zorro@zorrozou-pc0 bash]$ ls /etc/passwd
/etc/passwd
[zorro@zorrozou-pc0 bash]$ !!:p
ls /etc/passwd

q 防止代换参数被再次替换,相当于给选择的参数加上了’’,以防止其被转义。

[zorro@zorrozou-pc0 bash]$ ls `echo /etc/passwd`
/etc/passwd
[zorro@zorrozou-pc0 bash]$ !!:q
'ls `echo /etc/passwd`'
-bash: ls `echo /etc/passwd`: No such file or directory

x 作用同上,区别是每个参数都会分别给加上’’。如:

[zorro@zorrozou-pc0 bash]$ !-2:x
'ls' '`echo' '/etc/passwd`'
ls: cannot access '`echo': No such file or directory
ls: cannot access '/etc/passwd`': No such file or directory

s/old/new/ 字符串替换,跟上面的^^类似,但是可以指定任意历史命令。只替换找到的第一个old字符串。
& 重复上次替换
g 在执行s或者&命令作为前缀使用,表示全局替换。

资源限制

每一个进程环境中都有对于资源的限制,bash脚本也不例外。我们可以使用ulimit内建命令查看和设置bash环境中的资源限制。

[zorro@zorrozou-pc0 ~]$ ulimit -a
core file size (blocks, -c) unlimited
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 63877
max locked memory (kbytes, -l) 64
max memory size (kbytes, -m) unlimited
open files (-n) 1024
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 8192
cpu time (seconds, -t) unlimited
max user processes (-u) 63877
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited

在上文讲述bash和sh之间的区别时,我们已经接触过这个命令中的-c参数了,用来限制core文件的大小。我们再来看看其它参数的含义:

data seg size:程序的数据段限制。

scheduling priority:优先级限制。相关概念的理解可以参考这篇:http://wp.me/p79Cit-S

file size:文件大小限制。

pending signals:未决信号个数限制。

max locked memory:最大可以锁内存的空间限制。

max memory size:最大物理内存使用限制。

open files:文件打开个数限制。

pipe size:管道空间限制。

POSIX message queues:POSIX消息队列空间限制。

real-time priority:实时优先级限制。相关概念的理解可以参考这篇:http://wp.me/p79Cit-S

stack size:程序栈空间限制。

cpu time:占用CPU时间限制。

max user processes:可以打开的的进程个数限制。

virtual memory:虚拟内存空间限制。

file locks:锁文件个数限制。

以上参数涉及各方面的相关知识,我们在此就不详细描述这些相关内容了。在此我们主要关注open files和max user processes参数,这两个参数是我们在优化系统时最常用的两个参数。

这里需要注意的是,使用ulimit命令配置完这些参数之后的bash产生的子进程都会继承父进程的相关资源配置。ulimit的资源配置的继承关系类似环境变量,父进程的配置变化可以影响子进程。所以,如果我们只是在某个登录shell或者交互式shell中修改了ulimit配置,那么在这个bash环境中执行的命令和产生的子进程都会受到影响,但是对整个系统的其它进程没有影响。如果我们想要让所有用户一登录就有相关的配置,可以考虑把ulimit命令写在bash启动的相关脚本中,如/etc/profile。如果只想影响某一个用户,可以写在这个用户的主目录的bash启动脚本中,如~/.bash_profile。系统的pam模块也给我们提供了配置ulimit相关限制的配置方法,在centos7中大家可以在以下目录和文件中找到相关配置:

[zorro@zorrozou-pc0 bash]$ ls /etc/security/limits.d/
10-gcr.conf 99-audio.conf
[zorro@zorrozou-pc0 bash]$ ls /etc/security/limits.conf 
/etc/security/limits.conf

即使是写在pam相关配置文件中的相关配置,也可能不是系统全局的。如果你想给某一个后台进程设置ulimit,最靠谱的办法还是在它的启动脚本中进行配置。无论如何,只要记得一点,如果相关进程的ulimit没生效,要想的是它的父进程是谁?它的父进程是不是生效了?

ulimit参数中绝大多数配置都是root才有权限改的更大,而非root身份只能在现有的配置基础上减小限制。如果你执行ulimit的时候报错了,请注意是不是这个原因。

最后

通过本文我们学习了bash编程的进程环境的相关内容,主要包括的知识点为:

  1. bash的常用参数。
  2. bash的环境变量。
  3. 命令历史功能和相关变量配置。
  4. bash脚本的资源限制ulimit的使用。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

希望这些内容对大家进一步深入了解bash编程有帮助。如果有相关问题,可以在我的微博、微信或者博客上联系我。


大家好,我是Zorro!

如果你喜欢本文,欢迎在微博上搜索“orroz”关注我,地址是:http://weibo.com/orroz

大家也可以在微信上搜索:Linux系统技术 关注我的公众号。

我的所有文章都会沉淀在我的个人博客上,地址是:http://liwei.life。

欢迎使用以上各种方式一起探讨学习,共同进步。

公众号二维码:

Zorro] icon


 

SHELL编程之执行过程

 

SHELL编程之执行过程

版权声明:

本文章内容在非商业使用前提下可无需授权任意转载、发布。

转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:**orroz**

微信公众号:**Linux系统技术**

前言

本文是shell编程系列的第二篇,主要介绍bash脚本是如何执行命令的。通过本文,您应该可以解决以下问题:

  1. 脚本开始的#!到底是怎么起作用的?
  2. bash执行过程中的字符串判断顺序究竟是什么样?
  3. 如果我们定义了一个函数叫ls,那么调用ls的时候,到底bash是执行ls函数还是ls命令?
  4. 内建命令和外建命令到底有什么差别?
  5. 程度退出的时候要注意什么?

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

以魔法#!开始

一个脚本程序的开始方式都比较统一,它们几乎都开始于一个#!符号。这个符号的作用大家似乎也都知道,叫做声明解释器。脚本语言跟编译型语言的不一样之处主要是脚本语言需要解释器。因为脚本语言主要是文本,而系统中能够执行的文件实际上都是可执行的二进制文件,就是编译好的文件。文本的好处是人看方便,但是操作系统并不能直接执行,所以就需要将文本内容传递给一个可执行的二进制文件进行解析,再由这个可执行的二进制文件根据脚本的内容所确定的行为进行执行。可以做这种解析执行的二进制可执行程序都可以叫做解释器。

脚本开头的#!就是用来声明本文件的文本内容是交给那个解释器进行解释的。比如我们写bash脚本,一般声明的方法是#!/bin/bash或#!/bin/sh。如果写的是一个python脚本,就用#!/usr/bin/python。当然,在不同环境的系统中,这个解释器放的路径可能不一样,所以固定写一个路径的方式就可能造成脚本在不同环境的系统中不通用的情况,于是就出现了这样的写法:

#!/usr/bin/env 脚本解释器名称

这就利用了env命令可以得到可执行程序执行路径的功能,让脚本自行找到在当前系统上到底解释器在什么路径。让脚本更具通用性。但是大家有没有想过一个问题,大多数脚本语言都是将#后面出现的字符当作是注释,在脚本中并不起作用。这个#!和这个注释的规则不冲突么?

这就要从#!符号起作用的原因说起,其实也很简单,这个功能是由操作系统的程序载入器做的。在Linux操作系统上,出了1号进程以外,我们可以认为其它所有进程都是由父进程fork出来的。所以对bash来说,所谓的载入一个脚本执行,无非就是父进程调用fork()、exec()来产生一个子进程。这#!就是在内核处理exec的时候进行解析的。

内核中整个调用过程如下(linux 4.4),内核处理exec族函数的主要实现在fs/exec.c文件的do_execveat_common()方法中,其中调用exec_binprm()方法处理执行逻辑,这函数中使用search_binary_handler()对要加载的文件进行各种格式的判断,脚本(script)只是其中的一种。确定是script格式后,就会调用script格式对应的load_binary方法:load_script()进行处理,#!就是在这个函数中解析的。解析到了#!以后,内核会取其后面的可执行程序路径,再传递给search_binary_handler()重新解析。这样最终找到真正的可执行二进制文件进行相关执行操作。

因此,对脚本第一行的#!解析,其实是内核给我们变的魔术。#!后面的路径内容在起作用的时候还没有交给脚本解释器。很多人认为#!这一行是脚本解释器去解析的,然而并不是。了解了原理之后,也顺便明白了为什么#!一定要写在第一行的前两个字符,因为这是在内核里写死的,它就只检查前两个字符。当内核帮你选好了脚本解释器之后,后续的工作就都交给解释器做了。脚本的所有内容也都会原封不动的交给解释器再次解释,是的,包括#!。但是由于对于解释器来说,#开头的字符串都是注释,并不生效,所以解释器自然对#!后面所有的内容无感,继续解释对于它来说有意义的字符串去了。

我们可以用一个自显示脚本来观察一下这个事情,什么是自显示脚本?无非就是#!/bin/cat,这样文本的所有内容包括#!行都会交给cat进行显示:

[zorro@zorrozou-pc0 bash]$ cat cat.sh 
#!/bin/cat

echo "hello world!"
[zorro@zorrozou-pc0 bash]$ ./cat.sh 
#!/bin/cat

echo "hello world!"

或者自删除脚本:

[zorro@zorrozou-pc0 bash]$ cat rm.sh 
#!/bin/rm

echo "hello world!"
[zorro@zorrozou-pc0 bash]$ chmod +x rm.sh 
[zorro@zorrozou-pc0 bash]$ ./rm.sh 
[zorro@zorrozou-pc0 bash]$ cat rm.sh
cat: rm.sh: No such file or directory

这就是#!的本质。

bash如何执行shell命令?

刚才我们从#!的作用原理讲解了一个bash脚本是如何被加载的。就是说当#!/bin/bash的时候,实际上内核给我们启动了一个bash进程,然后把脚本内容都传递给bash进行解析执行。实际上,无论在脚本里还是在命令行中,bash对文本的解析方法大致都是一样的。首先,bash会以一些特殊字符作为分隔符,将文本进行分段解析。最主要的分隔符无疑就是回车,类似功能的分隔符还有分号”;”。所以在bash脚本中是以回车或者分号作为一行命令结束的标志的。这基本上就是第一层级的解析,主要目的是将大段的命令行进行分段。

之后是第二层级解析,这一层级主要是区分所要执行的命令。这一层级主要解析的字符是管道”|”,&&、||这样的可以起到连接命令作用的特殊字符。这一层级解析完后,bash就能拿到最基本的一个个的要执行的命令了。

当然拿到命令之后还要继续第三层解析,这一层主要是区分出要执行的命令和其参数,主要解析的是空格和tab字符。这一层次解析完之后,bash才开始对最基本的字符串进行解释工作。当然,绝大多数解析完的字符串,bash都是在fork之后将其传递给exec进行执行,然后wait其执行完毕之后再解析下一行。这就是bash脚本也被叫做批处理脚本的原因,主要执行过程是一个一个指令串行执行的,上一个执行完才执行下一个。以上这个过程并不能涵盖bash解释字符串的全过程,实际情况要比这复杂。

bash在解释命令的时候为了方便一些操作和提高某些效率做了不少特性,包括alias功能和外部命令路径的hash功能。bash还因为某些功能不能做成外部命令,所以必须实现一些内建命令,比如cd、pwd等命令。当然除了内建命令以外,bash还要实现一些关键字,比如其编程语法结构的if或是while这样的功能。实际上作为一种编程语言,bash还要实现函数功能,我们可以理解为,bash的函数就是将一堆命令做成一个命令,然后调用执行这个名字,bash就是去执行事先封装好的那堆命令。

好吧,问题来了:我们已知有一个内建命令叫做cd,如果此时我们又建立一个alias也叫cd,那么当我在bash中敲入cd并回车之后,bash究竟是将它当成内建命令解释还是当成alias解释?同样,如果cd又是一个外部命令能?如果又是一个hash索引呢?如果又是一个关键字或函数呢?

实际上bash在做这些功能的时候已经安排好了它们在名字冲突的情况下究竟该先以什么方式解释。优先顺序是:

  1. 别名:alias
  2. 关键字:keyword
  3. 函数:function
  4. 内建命令:built in
  5. 哈西索引:hash
  6. 外部命令:command

这些bash要判断的字符串类型都可以用type命令进行判断,如:

[zorro@zorrozou-pc0 bash]$ type egrep
egrep is aliased to `egrep --color=auto'
[zorro@zorrozou-pc0 bash]$ type if
if is a shell keyword
[zorro@zorrozou-pc0 bash]$ type pwd
pwd is a shell builtin
[zorro@zorrozou-pc0 bash]$ type passwd
passwd is /usr/bin/passwd

别名alias

bash提供了一种别名(alias)功能,可以将某一个字符串做成另一个字符串的别名,使用方法如下:

[zorro@zorrozou-pc0 bash]$ alias cat='cat -n'
[zorro@zorrozou-pc0 bash]$ cat /etc/passwd
     1  root:x:0:0:root:/root:/bin/bash
     2  bin:x:1:1:bin:/bin:/usr/bin/nologin
     3  daemon:x:2:2:daemon:/:/usr/bin/nologin
     4  mail:x:8:12:mail:/var/spool/mail:/usr/bin/nologin
     ......

于是我们再使用cat命令的时候,bash会将其解释为cat -n。

这个功能在交互方式进行bash操作的时候可以提高不少效率。如果我们发现我们常用到某命令的某个参数的时候,就可以将其做成alias,以后就可以方便使用了。交互bash中,我们可以用alias命令查看目前已经有的alias列表。可以用unalias取消这个别名设置:

[zorro@zorrozou-pc0 bash]$ alias 
alias cat='cat -n'

[zorro@zorrozou-pc0 bash]$ unalias cat

alias功能在交互打开的bash中是默认开启的,但是在bash脚本中是默认关闭的。

#!/bin/bash

#shopt -s expand_aliases

alias ls='ls -l'
ls /etc

此时本程序输出:

[zorro@zorrozou-pc0 bash]$ ./alias.sh 
adjtime       cgconfig.conf         docker       group      ifplugd     libao.conf      mail.rc      netconfig       passwd   request-key.conf  shells         udisks2
adobe         cgrules.conf          drirc   ...

使用注释行中的shopt -s expand_aliases命令可以打开alias功能支持,我们将这行注释取消掉之后的执行结果为:

[zorro@zorrozou-pc0 bash]$ ./alias.sh 
total 1544
-rw-r--r-- 1 root    root        44 11月 13 19:53 adjtime
drwxr-xr-x 2 root    root      4096 4月  20 09:34 adobe
-rw-r--r-- 1 root    root       389 4月  18 22:19 appstream.conf
-rw-r--r-- 1 root    root         0 10月  1 2015 arch-release
-rw-r--r-- 1 root    root       260 7月   1 2014 asound.conf
drwxr-xr-x 3 root    root      4096 3月  11 10:09 avahi

这就是bash的alias功能。

关键字:keyword

关键字的概念很简单,主要就是bash提供的语法。比如if,while,function等等。对这些关键字使用type命令会显示:

[zorro@zorrozou-pc0 bash]$ type function
function is a shell keyword

说明这是一个keyword。我想这个概念没什么可以解释的了,无非就是bash提供的一种语法而已。只是要注意,bash会在判断alias之后才来判断字符串是不是个keyword。就是说,我们还是可以创建一个叫if的alias,并且在执行的时候,bash只把它当成alias看。

[zorro@zorrozou-pc0 bash]$ alias if='echo zorro'
[zorro@zorrozou-pc0 bash]$ if
zorro
[zorro@zorrozou-pc0 bash]$ unalias if

函数:function

bash在判断完字符串不是一个关键字之后,将会检查其是不是一个函数。在bash编程中,我们可以使用关键字function来定义一个函数,当然这个关键字其实也可以省略:

   name () compound-command [redirection]
   function name [()] compound-command [redirection]

语法结构中的compound-command一般是放在{}里的一个命令列表(list)。定义好的函数其实就是一系列shell命令的封装,并且它还具有很多bash程序的特征,比如在函数内部可以使用$1,$2等这样的变量来判断函数的参数,也可以对函数使用重定向功能。

关于函数的更细节讨论我们会在后续的文章中展开说明,再这里我们只需要知道它对于bash来说是第几个被解释的即可。

内建命令:built in

在判断完函数之后,bash将查看给的字符串是不是一个内建命令。内建命令是相对于外建命令来说的。其实我们在bash中执行的命令最常见的是外建(外部)命令。比如常见的ls,find,passwd等。这些外建命令的特点是,它们是作为一个可执行程序放在$PATH变量所包含的目录中的。bash在执行这些命令的时候,都会进行fork(),exec()并且wait()。就是用标准的打开子进程的方式处理外部命令。但是内建命令不同,这些命令都是bash自身实现的命令,它们不依靠外部的可执行文件存在。只要有bash,这些命令就可以执行。典型的内建命令有cd、pwd等。大家可以直接help cd或者任何一个内建命令来查看它们的帮助。大家还可以man bash来查看bash相关的帮助,当然也包括所有的内建命令。

其实内建命令的个数并不会很多,一共大概就这些:

:,  ., [, alias, bg, bind, break, builtin, caller, cd, command, compgen, complete, compopt, continue, declare, dirs, disown, echo, enable, eval, exec, exit, export, false, fc,
   fg, getopts, hash, help, history, jobs, kill, let, local, logout, mapfile, popd, printf, pushd, pwd, read, readonly, return, set, shift, shopt, source, suspend,  test,  times,  trap,
   true, type, typeset, ulimit, umask, unalias, unset, wait

我们在后续的文章中会展开讲解这些命令的功能。

哈西索引:hash

hash功能实际上是针对外部命令做的一个功能。刚才我们已经知道了,外部命令都是放在$PATH变量对应的路径中的可执行文件。bash在执行一个外部命令时所需要做的操作是:如果发现这个命令是个外部命令就按照$PATH变量中按照目录路径的顺序,在每个目录中都遍历一遍,看看有没有对应的文件名。如果有,就fork、exec、wait。我们系统上一般的$PATH内容如下:

[zorro@zorrozou-pc0 bash]$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/lib/jvm/default/bin:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl:/home/zorro/.local/bin:/home/zorro/bin

当然,很多系统上的$PATH变量包含的路径可能更多,目录中的文件数量也可能会很多。于是,遍历这些目录去查询文件名的行为就可能比较耗时。于是bash提供了一种功能,就是建立一个bash表,在第一次找到一个命令的路径之后,对其命令名和对应的路径建立一个hash索引。这样下次再执行这个命令的时候,就不用去遍历所有的目录了,只要查询索引就可以更快的找到命令路径,以加快执行程序的速度。

我们可以使用内建命令hash来查看当前已经建立缓存关系的命令和其命中次数:

[zorro@zorrozou-pc0 bash]$ hash
hits    command
   1    /usr/bin/flock
   4    /usr/bin/chmod
  20    /usr/bin/vim
   4    /usr/bin/cat
   1    /usr/bin/cp
   1    /usr/bin/mkdir
  16    /usr/bin/man
  27    /usr/bin/ls

这个命令也可以对当前的hash表进行操作,-r参数用来清空当前hash表。手工创建一个hash:

[root@zorrozou-pc0 bash]# hash -p /usr/sbin/passwd psw
[root@zorrozou-pc0 bash]# psw
Enter new UNIX password: 
Retype new UNIX password: 

此时我们就可以通过执行psw来执行passwd命令了。查看更详细的hash对应关系:

[root@zorrozou-pc0 bash]# hash -l
builtin hash -p /usr/bin/netdata netdata
builtin hash -p /usr/bin/df df
builtin hash -p /usr/bin/chmod chmod
builtin hash -p /usr/bin/vim vim
builtin hash -p /usr/bin/ps ps
builtin hash -p /usr/bin/man man
builtin hash -p /usr/bin/pacman pacman
builtin hash -p /usr/sbin/passwd psw
builtin hash -p /usr/bin/ls ls
builtin hash -p /usr/bin/ss ss
builtin hash -p /usr/bin/ip ip

删除某一个hash对应:

[root@zorrozou-pc0 bash]# hash -d psw
[root@zorrozou-pc0 bash]# hash -l
builtin hash -p /usr/bin/netdata netdata
builtin hash -p /usr/bin/df df
builtin hash -p /usr/bin/chmod chmod
builtin hash -p /usr/bin/vim vim
builtin hash -p /usr/bin/ps ps
builtin hash -p /usr/bin/man man
builtin hash -p /usr/bin/pacman pacman
builtin hash -p /usr/bin/ls ls
builtin hash -p /usr/bin/ss ss
builtin hash -p /usr/bin/ip ip

显示某一个hash对应的路径:

[root@zorrozou-pc0 bash]# hash -t chmod
/usr/bin/chmod

在交互式bash操作和bash编程中,hash功能总是打开的,我们可以用set +h关闭hash功能。

[zorro@zorrozou-pc0 bash]$ cat hash.sh 
#!/bin/bash

#set +h

hash

hash -p /usr/bin/useradd uad

hash -t uad

uad

默认打开hash的脚本输出:

[zorro@zorrozou-pc0 bash]$ ./hash.sh 
hash: hash table empty
/usr/bin/useradd
Usage: uad [options] LOGIN
       uad -D
       uad -D [options]

Options:
  -b, --base-dir BASE_DIR       base directory for the home directory of the
                            new account
  -c, --comment COMMENT         GECOS field of the new account
  -d, --home-dir HOME_DIR       home directory of the new account
  -D, --defaults                print or change default useradd configuration
  -e, --expiredate EXPIRE_DATE  expiration date of the new account
  -f, --inactive INACTIVE       password inactivity period of the new account
  -g, --gid GROUP               name or ID of the primary group of the new
                            account
  -G, --groups GROUPS           list of supplementary groups of the new
                            account
  -h, --help                    display this help message and exit
  -k, --skel SKEL_DIR           use this alternative skeleton directory
  -K, --key KEY=VALUE           override /etc/login.defs defaults
  -l, --no-log-init             do not add the user to the lastlog and
                            faillog databases
  -m, --create-home             create the user's home directory
  -M, --no-create-home          do not create the user's home directory
  -N, --no-user-group           do not create a group with the same name as
                            the user
  -o, --non-unique              allow to create users with duplicate
                            (non-unique) UID
  -p, --password PASSWORD       encrypted password of the new account
  -r, --system                  create a system account
  -R, --root CHROOT_DIR         directory to chroot into
  -s, --shell SHELL             login shell of the new account
  -u, --uid UID                 user ID of the new account
  -U, --user-group              create a group with the same name as the user

关闭hash之后的输出:

[zorro@zorrozou-pc0 bash]$ ./hash.sh 
./hash.sh: line 5: hash: hashing disabled
./hash.sh: line 7: hash: hashing disabled
./hash.sh: line 9: hash: hashing disabled
./hash.sh: line 11: uad: command not found

外部命令:command

除了以上说明之外的命令都会当作外部命令处理。执行外部命令的固定动作就是在$PATH路径下找命令,找到之后fork、exec、wait。如果没有这个可执行文件名,就报告命令不存在。这也是bash最后去判断的字符串类型。

外建命令都是通过fork调用打开子进程执行的,所以bash单纯只用外建命令是不能实现部分功能的。比如大家都知道cd命令是用来修改当前进程的工作目录的,如果这个功能使用外部命令实现,那么进程将fork打开一个子进程,子进程通过chdir()进行当前工作目录的修改时,实际上只改变了子进程本身的当前工作目录,而父进程bash的工作目录没变。之后子进程退出,返回到父进程的交互操作环境之后,用户会发现,当前的bash的pwd还在原来的目录下。所以大家应该可以理解,虽然我们的原则是尽量将所有命令都外部实现,但是还是有一些功能不能以创建子进程的方式达到目的,那么这些功能就必须内部实现。这就是内建命令必须存在的原因。另外要注意:bash在正常调用内部命令的时候并不会像外部命令一样产生一个子进程

脚本的退出

一个bash脚本的退出一般有多种方式,比如使用exit退出或者所有脚本命令执行完之后退出。无论怎么样退出,脚本都会有个返回码,而且返回码可能不同。

任何命令执行完之后都有返回码,主要用来判断这个命令是否执行成功。在交互中bash中,我们可以使用$?来查看上一个命令的返回码:

[zorro@zorrozou-pc0 bash]$ ls /123
ls: cannot access '/123': No such file or directory
[zorro@zorrozou-pc0 bash]$ echo $?
2
[zorro@zorrozou-pc0 bash]$ ls /
bin  boot  cgroup  data  dev  etc  home  lib  lib64  lost+found  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
[zorro@zorrozou-pc0 bash]$ echo $?
0

返回码逻辑上有两类,0为真,非零为假。就是说,返回为0表示命令执行成功,非零表示执行失败。返回码的取值范围为0-255。其中错误返回码为1-255。bash为我们提供了一个内建命令exit,通过中这个命令可以人为指定退出的返回码是多少。这个命令的使用是一般进行bash编程的运维人员所不太注意的。我们在上一篇的bash编程语法结构的讲解中说过,if、while语句的条件判断实际上就是判断命令的返回值,如果我们自己写的bash脚本不注意规范的使用脚本退出时的返回码的话,那么这样的bash脚本将可能不可以在别人编写脚本的时候,直接使用if将其作为条件判断,这可能会对程序的兼容性造成影响。因此,请大家注意自己写的bash程序的返回码状态。如果我们的bash程序没有显示的以一个exit指定返回码退出的话,那么其最后执行命令的返回码将成为整个bash脚本退出的返回码。

当然,一个bash程序的退出还可能因为被中间打断而发生,这一般是因为进程接收到了一个需要程序退出的信号。比如我们日常使用的ctrl+c操作,就是给进程发送了一个2号SIGINT信号。考虑到程序退出可能性的各种可能,系统将错误返回码设计成1-255,这其中还分成两类:

  1. 程序退出的返回码:1-127。这部分返回码一般用来作为给程序员自行设定错误退出用的返回码,比如:如果一个文件不存在,ls将返回2。如果要执行的命令不存在,则bash统一返回127。返回码125盒126有特殊用处,一个是程序命令不存在的返回码,另一个是命令的文件在,但是不可执行的返回码。
  2. 程序被信号打断的返回码:128-255。这部分系统习惯上是用来表示进程被信号打断的退出返回码的。一个进程如果被信号打断了,其退出返回码一般是128+信号编号的数字。

比如说,如果一个进程被2号信号打断的话,其返回码一般是128+2=130。如:

[zorro@zorrozou-pc0 bash]$ sleep 1000
^C
[zorro@zorrozou-pc0 bash]$ echo $?
130

在执行sleep命令的过程中,我使用ctrl+c中断了进程的执行。此时返回值为130。可以用内建命令kill -l查看所有信号和其对应的编号:

[zorro@zorrozou-pc0 bash]$ kill -l
 1) SIGHUP   2) SIGINT   3) SIGQUIT  4) SIGILL   5) SIGTRAP
 6) SIGABRT  7) SIGBUS   8) SIGFPE   9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG  24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF 28) SIGWINCH    29) SIGIO   30) SIGPWR
31) SIGSYS  34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX    

在我们编写bash脚本的时候,一般可以指定的返回码范围是1-124。建议大家养成编写返回码的编程习惯,但是系统并不对这做出限制,作为程序员你依然可以使用0-255的所有返回码。但是如果你滥用这些返回码,很可能会给未来程序的扩展造成不必要的麻烦。

最后

本文中我们描述了一个脚本的执行过程,从#!开始,到中间的解析过程,再到最后的退出返回码。希望这些对大家深入理解bash的执行过程和编写更高质量的脚本有帮助。通过本文我们明确了以下知识点:

  1. 脚本开始的#!的作用原理。
  2. bash的字符串解析过程。
  3. 什么是alias。
  4. 什么是关键字。
  5. 什么是function。
  6. 什么是内建命令,hash和外建命令以及它们的执行方法。
  7. 如何退出一个bash脚本以及返回码的含义。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

希望这些内容会对大家以后的bash编程有所帮助。如果有相关问题,可以在我的微博、微信或者博客上联系我。


大家好,我是Zorro!

如果你喜欢本文,欢迎在微博上搜索“orroz”关注我,地址是:http://weibo.com/orroz

大家也可以在微信上搜索:Linux系统技术 关注我的公众号。

我的所有文章都会沉淀在我的个人博客上,地址是:http://liwei.life。

欢迎使用以上各种方式一起探讨学习,共同进步。

公众号二维码:

Zorro] icon


 

SHELL编程之语法基础

 

SHELL编程之语法基础

版权声明:

本文章内容在非商业使用前提下可无需授权任意转载、发布。

转载、发布请务必注明作者和其微博、微信公众号地址,以便读者询问问题和甄误反馈,共同进步。

微博ID:**orroz**

微信公众号:**Linux系统技术**

前言

在此需要特别注明一下,本文叫做shell编程其实并不准确,更准确的说法是bash编程。考虑到bash的流行程度,姑且将bash编程硬说成shell编程也应没什么不可,但是请大家一定要清楚,shell编程绝不仅仅只是bash编程。

通过本文可以帮你解决以下问题:

  1. if后面的中括号[]是语法必须的么?
  2. 为什么bash编程中有时[]里面要加空格,有时不用加?如if [ -e /etc/passwd ]或ls [abc].sh。
  3. 为什么有人写的脚本这样写:if [ x$test = x”string” ]?
  4. 如何用*号作为通配符对目录树递归匹配?
  5. 为什么在for循环中引用ls命令的输出是可能有问题的?就是说:for i in $(ls /)这样用有问题?

除了以上知识点以外,本文还试图帮助大家用一个全新的角度对bash编程的知识进行体系化。介绍shell编程传统的做法一般是要先说明什么是shell?什么是bash?这是种脚本语言,那么什么是脚本语言?不过这些内容真的太无聊了,我们快速掠过,此处省略3万字……作为一个实践性非常强的内容,我们直接开始讲语法。所以,这并不是一个入门内容,我们的要求是在看本文之前,大家至少要学会Linux的基本操作,并知道bash的一些基础知识。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

if分支结构

组成一个语言的必要两种语法结构之一就是分支结构,另一种是循环结构。作为一个编程语言,bash也给我们提供了分支结构,其中最常用的就是if。用来进行程序的分支逻辑判断。其原型声明为:

if list; then list; elif list; then list; ... else list; fi

bash在解析字符的时候,对待“;”跟看见回车是一样的行为,所以这个语法也可以写成:

if list
then
    list
elif list
then
    list
...
else
    list
fi

对于这个语法结构,需要重点说明的是list。对于绝大多数其他语言,if关键字后面一般跟的是一个表达式,比如C语言或类似语言的语法,if后面都是跟一个括号将表达式括起来,如:if (a > 0)。这种认识会对学习bash编程造成一些误会,很多初学者都认为bash编程的if语法结构是:if [ ];then…,但实际上这里的中括号[]并不是C语言中小括号()语法结构的类似的关键字。这里的中括号其实就是个shell命令,是test命令的另一种写法。严谨的叙述,if后面跟的就应该是个list。那么什么是bash中的list呢?根据bash的定义,list就是若干个使用管道,;,&,&&,||这些符号串联起来的shell命令序列,结尾可以;,&或换行结束。这个定义可能比较复杂,如果暂时不能理解,大家直接可以认为,if后面跟的就是个shell命令。换个角度说,bash编程仍然贯彻了C程序的设计哲学,即:一切皆表达式

一切皆表达式这个设计原则,确定了shell在执行任何东西(注意是任何东西,不仅是命令)的时候都会有一个返回值,因为根据表达式的定义,任何表达式都必须有一个值。在bash编程中,这个返回值也限定了取值范围:0-255。跟C语言含义相反,bash中0为真(true),非0为假(false)。这就意味着,任何给bash之行的东西,都会反回一个值,在bash中,我们可以使用关键字$?来查看上一个执行命令的返回值:

[zorro@zorrozou-pc0 ~]$ ls /tmp/
plugtmp  systemd-private-bfcfdcf97a4142e58da7d823b7015a1f-colord.service-312yQe  systemd-private-bfcfdcf97a4142e58da7d823b7015a1f-systemd-timesyncd.service-zWuWs0  tracker-extract-files.1000
[zorro@zorrozou-pc0 ~]$ echo $?
0
[zorro@zorrozou-pc0 ~]$ ls /123
ls: cannot access '/123': No such file or directory
[zorro@zorrozou-pc0 ~]$ echo $?
2

可以看到,ls /tmp命令执行的返回值为0,即为真,说明命令执行成功,而ls /123时文件不存在,反回值为2,命令执行失败。我们再来看个更极端的例子:

[zorro@zorrozou-pc0 ~]$ abcdef
bash: abcdef: command not found
[zorro@zorrozou-pc0 ~]$ echo $?
127

我们让bash执行一个根本不存在的命令abcdef。反回值为127,依然为假,命令执行失败。复杂一点:

[zorro@zorrozou-pc0 ~]$ ls /123|wc -l
ls: cannot access '/123': No such file or directory
0
[zorro@zorrozou-pc0 ~]$ echo $?
0

这是一个list的执行,其实就是两个命令简单的用管道串起来。我们发现,这时shell会将整个list看作一个执行体,所以整个list就是一个表达式,那么最后只返回一个值0,这个值是挣个list中最后一个命令的返回值,第一个命令执行失败并不影响后面的wc统计行数,所以逻辑上这个list执行成功,返回值为真。

理解清楚这一层意思,我们才能真正理解bash的语法结构中if后面到底可以判断什么?事实是,判断什么都可以,因为bash无非就是把if后面的无论什么当成命令去执行,并判断其起返回值是真还是假?如果是真则进入一个分支,为假则进入另一个。基于这个认识,我们可以来思考以下这个程序两种写法的区别:

#!/bin/bash

DIR="/etc"
#第一种写法
ls -l $DIR &> /dev/null
ret=$?

if [ $ret -eq 0 ]
then
        echo "$DIR is exist!" 
else
        echo "$DIR is not exist!"
fi

#第二种写法
if ls -l $DIR &> /dev/null
then
        echo "$DIR is exist!" 
else
        echo "$DIR is not exist!"
fi

我曾经在无数的脚本中看到这里的第一种写法,先执行某个命令,然后记录其返回值,再使用[]进行分支判断。我想,这样写的人应该都是没有真正理解if语法的语义,导致做出了很多脱了裤子再放屁的事情。当然,if语法中后面最常用的命令就是[]。请注意我的描述中就是说[]是一个命令,而不是别的。实际上这也是bash编程新手容易犯错的地方之一,尤其是有其他编程经验的人,在一开始接触bash编程的时候都是将[]当成if语句的语法结构,于是经常在写[]的时候里面不写空格,即:

#正确的写法
if [ $ret -eq 0 ]
#错读的写法
if [$ret -eq 0]

同样的,当我们理解清楚了[]本质上是一个shell命令的时候,大家就知道这个错误是为什么了:命令加参数要用空格分隔。我们可以用type命令去检查一个命令:

[zorro@zorrozou-pc0 bash]$ type [
[ is a shell builtin

所以,实际上[]是一个内建命令,等同于test命令。所以上面的if语句也可以写成:

if test $ret -eq 0

这样看,形式上就跟第二种写法类似了。至于if分支怎么使用的其它例子就不再这废话了。重要的再强调一遍:if后面是一个命令(严格来说是list),并且记住一个原则:一切皆表达式

“当”、“直到”循环结构

一般角度的讲解都会在讲完if分支结构之后讲其它分支结构,但是从执行特性和快速上手的角度来看,我认为先把跟if特性类似的while和until交代清楚更加合理。从字面上可以理解,while就是“当”型循环,指的是当条件成立时执行循环。,而until是直到型循环,其实跟while并没有实质上的区别,只是条件取非,逻辑变成循环到条件成立,或者说条件不成立时执行循环体。他们的语法结构是:

   while list-1; do list-2; done
   until list-1; do list-2; done

同样,分号可以理解为回车,于是常见写法是:

while list-1
do
    list-2
done

until list-1
do
    list-2
done

还是跟if语句一样,我们应该明白对与while和until的条件的含义,仍然是list。其判断条件是list,其执行结构也是list。理解了上面if的讲解,我想这里应该不用复述了。我们用while和unitl来产生一个0-99的数字序列:
while版:

#!/bin/bash

count=0

while [ $count -le 100 ]
do
    echo $count
    count=$[$count+1]
done

until版:

#!/bin/bash

count=0

until ! [ $count -le 100 ]
do
    echo $count
    count=$[$count+1]
done

我们通过这两个程序可以再次对比一下while和until到底有什么不一样?其实它们从形式上完全一样。这里另外说明两个知识点:

  1. 在bash中,叹号(!)代表对命令(表达式)的返回值取反。就是说如果一个命令或list或其它什么东西如果返回值为真,加了叹号之后就是假,如果是假,加了叹号就是真。
  2. 在bash中,使用$[]可以得到一个算数运算的值。可以支持常用的5则运算(+-*/%)。用法就是$[3+7]类似这样,而且要注意,这里的$[]里面没有空格分隔,因为它并不是个shell命令,而是特殊字符

常见运算例子:

[zorro@zorrozou-pc0 bash]$ echo $[213+456]
669
[zorro@zorrozou-pc0 bash]$ echo $[213+456+789]
1458
[zorro@zorrozou-pc0 bash]$ echo $[213*456]
97128
[zorro@zorrozou-pc0 bash]$ echo $[213/456]
0
[zorro@zorrozou-pc0 bash]$ echo $[9/3]
3
[zorro@zorrozou-pc0 bash]$ echo $[9/2]
4
[zorro@zorrozou-pc0 bash]$ echo $[9%2]
1
[zorro@zorrozou-pc0 bash]$ echo $[144%7]
4
[zorro@zorrozou-pc0 bash]$ echo $[7-10]
-3

注意这个运算只支持整数,并且对与小数只娶其整数部分(没有四舍五入,小数全舍)。这个计算方法是bash提供的基础计算方法,如果想要实现更高级的计算可以使用let命令。如果想要实现浮点数运算,我一般使用awk来处理。

上面的例子中仍然使用[]命令(test)来作为检查条件,我们再试一个别的。假设我们想写一个脚本检查一台服务器是否能ping通?如果能ping通,则每隔一秒再看一次,如果发现ping不通了,就报警。如果什么时候恢复了,就再报告恢复。就是说这个脚本会一直检查服务器状态,ping失败则触发报警,ping恢复则通告恢复。脚本内容如下:

#!/bin/bash

IPADDR='10.0.0.1'
INTERVAL=1

while true
do
    while ping -c 1 $IPADDR &> /dev/null
    do
        sleep $INTERVAL
    done

    echo "$IPADDR ping error! " 1>&2

    until ping -c 1 $IPADDR &> /dev/null
    do
        sleep $INTERVAL
    done

    echo "$IPADDR ping ok!"
done

这里关于输出重定向的知识我就先不讲解了,后续会有别的文章专门针对这个主题做出说明。以上就是if分支结构和while、until循环结构。掌握了这两种结构之后,我们就可以写出几乎所有功能的bash脚本程序了。这两种语法结构的共同特点是,使用list作为“判断条件”,这种“风味”的语法特点是“一切皆表达式”。bash为了使用方便,还给我们提供了另外一些“风味”的语法。下面我们继续看:

case分支结构和for循环结构

case分支结构

我们之所以把case分支和for循环放在一起讨论,主要是因为它们所判断的不再是“表达式”是否为真,而是去匹配字符串。我们还是通过其语法和例子来理解一下。case分支的语法结构:

   case word in [ [(] pattern [ | pattern ] ... ) list ;; ] ... esac

与if语句是以fi标记结束思路相仿,case语句是以esac标记结束。其常见的换行版本是:

case $1 in
        pattern)
        list
        ;;
        pattern)
        list
        ;;
        pattern)
        list
        ;;
esac

举几个几个简单的例子,并且它们实际上是一样的:

例1:

#!/bin/bash

case $1 in
    (zorro)
    echo "hello zorro!"
    ;;
    (jerry)
    echo "hello jerry!"
    ;;
    (*)
    echo "get out!"
    ;;
esac

例2:

#!/bin/bash

case $1 in
    zorro)
    echo "hello zorro!"
    ;;
    jerry)
    echo "hello jerry!"
    ;;
    *)
    echo "get out!"
    ;;
esac

例3:

#!/bin/bash

case $1 in
    zorro|jerry)
    echo "hello $1!"
    ;;
    *)
    echo "get out!"
    ;;
esac

这些程序的执行结果都是一样的:

[zorro@zorrozou-pc0 bash]$ ./case.sh zorro
hello zorro!
[zorro@zorrozou-pc0 bash]$ ./case.sh jerry
hello jerry!
[zorro@zorrozou-pc0 bash]$ ./case.sh xxxxxx
get out!

这些程序应该不难理解,无非就是几个语法的不一样之处,大家自己可以看到哪些可以省略,哪些不能省略。这里需要介绍一下的有两个概念:

  1. $1在脚本中表示传递给脚本命令的第一个参数。关于这个变量以及其相关系列变量的使用,我们会在后续其它文章中介绍。
  2. pattern就是bash中“通配符”的概念。常用的bash通配符包括星号(*)、问号(?)和其它一些字符。相信如果对bash有一定了解的话,对这些符号并不陌生,我们在此简单说明一下。

最常见的通配符有三个:

? 表示任意一个字符。这个没什么可说的。

  • 表示任意长度任意字符,包括空字符。在bash4.0以上版本中,如果bash环境开启了globstar设置,那么两个连续的**可以用来递归匹配某目录下所有的文件名。我们通过一个实验测试一下:

一个目录的结构如下:

[zorro@zorrozou-pc0 bash]$ tree test/
test/
├── 1
├── 2
├── 3
├── 4
├── a
│   ├── 1
│   ├── 2
│   ├── 3
│   └── 4
├── a.conf
├── b
│   ├── 1
│   ├── 2
│   ├── 3
│   └── 4
├── b.conf
├── c
│   ├── 5
│   ├── 6
│   ├── 7
│   └── 8
└── d
    ├── 1.conf
    └── 2.conf

4 directories, 20 files

使用通配符进行文件名匹配:

[zorro@zorrozou-pc0 bash]$ echo test/*
test/1 test/2 test/3 test/4 test/a test/a.conf test/b test/b.conf test/c test/d
[zorro@zorrozou-pc0 bash]$ echo test/*.conf
test/a.conf test/b.conf

这个结果大家应该都熟悉。我们再来看看下面:

查看当前globstar状态:

[zorro@zorrozou-pc0 bash]$ shopt globstar
globstar        off

打开globstar:

[zorro@zorrozou-pc0 bash]$ shopt -s globstar
[zorro@zorrozou-pc0 bash]$ shopt globstar
globstar        on

使用**匹配:

[zorro@zorrozou-pc0 bash]$ echo test/**
test/ test/1 test/2 test/3 test/4 test/a test/a/1 test/a/2 test/a/3 test/a/4 test/a.conf test/b test/b/1 test/b/2 test/b/3 test/b/4 test/b.conf test/c test/c/5 test/c/6 test/c/7 test/c/8 test/d test/d/1.conf test/d/2.conf
[zorro@zorrozou-pc0 bash]$ echo test/**/*.conf
test/a.conf test/b.conf test/d/1.conf test/d/2.conf

关闭globstart并再次测试**:

[zorro@zorrozou-pc0 bash]$ shopt -u globstar
[zorro@zorrozou-pc0 bash]$ shopt  globstar
globstar        off

[zorro@zorrozou-pc0 bash]$ echo test/**/*.conf
test/d/1.conf test/d/2.conf
[zorro@zorrozou-pc0 bash]$ 
[zorro@zorrozou-pc0 bash]$ echo test/**
test/1 test/2 test/3 test/4 test/a test/a.conf test/b test/b.conf test/c test/d

[…] 表示这个范围中的任意一个字符。比如[abcd],表示a或b或c或d。当然这也可以写成[a-d]。[a-z]表示任意一个小些字母。还是刚才的test目录,我们再来试试:

[zorro@zorrozou-pc0 bash]$ ls test/[123]
test/1  test/2  test/3
[zorro@zorrozou-pc0 bash]$ ls test/[abc]
test/a:
1  2  3  4

test/b:
1  2  3  4

test/c:
5  6  7  8

以上就是简单的三个通配符的说明。当然,关于通配符以及shopt命令还有很多相关知识。我们还是会在后续的文章中单独把相关知识点拿出来讲,再这里大家先理解这几个。另外需要强调一点,千万不要把bash的通配符和正则表达式搞混了,它们完全没有关系!

简要理解了pattern的概念之后,我们就可以更加灵活的使用case了,它不仅仅可以匹配一个固定的字符串,还可以利用pattern做到一定程度的模糊匹配。但是无论怎样,case都是去比较字符串是否一样,这跟使用if语句有本质的不同,if是判断表达式。当然,我们在if中使用test命令同样可以做到case的效果,区别仅仅是程序代码多少的区别。还是举个例子说明一下,我们想写一个身份验证程序,大家都知道,一个身份验证程序要判断用户名及其密码是否都匹配某一个字符串,如果两个都匹配,就通过验证,如果有一个不匹配就不能通过验证。分别用if和case来实现这两个验证程序内容如下:

if版:

#!/bin/bash

if [ $1 = "zorro" ] && [ $2 = "zorro" ]
then
    echo "ok"
elif [ $1$2 = "jerryjerry" ]
then
    echo "ok"
else
    echo "auth failed!"
fi

case版:

#!/bin/bash

case $1$2 in
    zorrozorro|jerryjerry)
    echo "ok!"
    ;;
    *)
    echo "auth failed!"
    ;;
esac

两个程序一对比,直观看起来case版的要少些代码,表达力也更强一些。但是,这两个程序都有bug,如果case版程序给的两个参数是zorro zorro可以报ok。如果是zorroz orro是不是也可以报ok?如果只给一个参数zorrozorro,另一个参数为空,是不是也可以报ok?同样,if版的jerry判断也有类似问题。当你的程序要跟用户或其它程序交互的时候,一定要谨慎仔细的检查输入,一般写程序很大工作量都在做各种异常检查上,尤其是需要跟人交互的时候。我们看似用一个合并字符串变量的技巧,将两个判断给合并成了一个,但是这个技巧却使程序编写出了错误。对于这个现象,我的意见是,如果不是必要,请不要在编程中玩什么“技巧”,重剑无锋,大巧不工。当然,这个bug可以通过如下方式解决:

if版:

#!/bin/bash

if [ $1 = "zorro" ] && [ $2 = "zorro" ]
then
    echo "ok"
elif [ $1:$2 = "jerry:jerry" ]
then
    echo "ok"
else
    echo "auth failed!"
fi

case版:

#!/bin/bash

case $1x$2 in
    zorro:zorro|jerry:jerry)
    echo "ok!"
    ;;
    *)
    echo "auth failed!"
    ;;
esac

我加的是个:字符,当然,也可以加其他字符,原则是这个字符不要再输入中能出现。我们在其他人写的程序里也经常能看到类似这样的判断处理:

if [ x$1 = x"zorro" ] && [ x$2 = x"zorro" ]

相信你也能明白为什么要这么处理了。仅对某一个判断来说这似乎没什么必要,但是如果你养成了这样的习惯,那么就能让你避免很多可能出问题的环节。这就是编程经验和编程习惯的重要性。当然,很多人只有“经验”,却也不知道这个经验是怎么来的,那也并不可取。

for循环结构

bash提供了两种for循环,一种是类似C语言的for循环,另一种是让某变量在一系列字符串中做循环。在此,我们先说后者。其语法结构是:

for name [ [ in [ word ... ] ] ; ] do list ; done

其中name一般是一个变量名,后面的word …是我们要让这个变量分别赋值的字符串列表。这个循环将分别将name变量每次赋值一个word,并执行循环体,直到所有word被遍历之后退出循环。这是一个非常有用的循环结构,其使用频率可能远高于while、until循环。我们来看看例子:

[zorro@zorrozou-pc0 bash]$ for i in 1 2 3 4 5;do echo $i;done
1
2
3
4
5

再看另外一个例子:

[zorro@zorrozou-pc0 bash]$ for i in aaa bbb ccc ddd eee;do echo $i;done
aaa
bbb
ccc
ddd
eee

再看一个:

[zorro@zorrozou-pc0 bash]$ for i in /etc/* ;do echo $i;done
/etc/adjtime
/etc/adobe
/etc/appstream.conf
/etc/arch-release
/etc/asound.conf
/etc/avahi
......

这种例子举不胜举,可以用for遍历的东西真的很多,大家可以自己发挥想象力。这里要提醒大家注意的是当你学会了“或$()这个符号之后,for的范围就更大了。于是很多然喜欢这样搞:

[zorro@zorrozou-pc0 bash]$ for i in `ls`;do echo $i;done
auth_case.sh
auth_if.sh
case.sh
if_1.sh
ping.sh
test
until.sh
while.sh

乍看起来这好像跟使用*没啥区别:

[zorro@zorrozou-pc0 bash]$ for i in *;do echo $i;done
auth_case.sh
auth_if.sh
case.sh
if_1.sh
ping.sh
test
until.sh
while.sh

但可惜的是并不总是这样,请对比如下两个测试:

[zorro@zorrozou-pc0 bash]$ for i in `ls /etc`;do echo $i;done
adjtime
adobe
appstream.conf
arch-release
asound.conf
avahi
bash.bash_logout
bash.bashrc
bind.keys
binfmt.d
......


[zorro@zorrozou-pc0 bash]$ for i in /etc/*;do echo $i;done
/etc/adjtime
/etc/adobe
/etc/appstream.conf
/etc/arch-release
/etc/asound.conf
/etc/avahi
/etc/bash.bash_logout
/etc/bash.bashrc
/etc/bind.keys
/etc/binfmt.d
......

看到差别了么?

其实这里还会隐含很多其它问题,像ls这样的命令很多时候是设计给人用的,它的很多显示是有特殊设定的,可能并不是纯文本。比如可能包含一些格式化字符,也可能包含可以让终端显示出颜色的标记字符等等。当我们在程序里面使用类似这样的命令的时候要格外小心,说不定什么时候在什么不同环境配置的系统上,你的程序就会有意想不到的异常出现,到时候排查起来非常麻烦。所以这里我们应该尽量避免使用ls这样的命令来做类似的行为,用通配符可能更好。当然,如果你要操作的是多层目录文件的话,那么ls就更不能帮你的忙了,它遇到目录之后显示成这样:

[zorro@zorrozou-pc0 bash]$ ls /etc/*
/etc/adobe:
mms.cfg

/etc/avahi:
avahi-autoipd.action  avahi-daemon.conf  avahi-dnsconfd.action  hosts  services

/etc/binfmt.d:

/etc/bluetooth:
main.conf

/etc/ca-certificates:
extracted  trust-source

所以遍历一个目录还是要用刚才说到的**,如果不是bash 4.0之后的版本的话,可以使用find。我推荐用find,因为它更通用。有时候你会发现,使用find之后,绝大多数原来需要写脚本解决的问题可能都用不着了,一个find命令解决很多问题。

select和第二种for循环

我之所以把这两种语法放到一起讲,主要是这两种语法结构在bash编程中使用的几率可能较小。这里的第二种for循环是相对于上面讲的第一种for循环来说的。实际上这种for循环就是C语言中for循环的翻版,其语义基本一致,区别是括号()变成了双括号(()),循环标记开始和结束也是bash风味的do和done,其语法结构为:

for (( expr1 ; expr2 ; expr3 )) ; do list ; done

看一个产生0-99数字的循环例子:

#!/bin/bash

for ((count=0;count<100;count++))
do
    echo $count
done

我们可以理解为,bash为了对数学运算作为条件的循环方便我们使用,专门扩展了一个for循环来给我们使用。跟C语言一样,这个循环本质上也只是一个while循环,只是把变量初始化,变量比较和循环体中的变量操作给放到了同一个(())语句中。这里不再废话。

最后是select循环,实际上select提供给了我们一个构建交互式菜单程序的方式,如果没有select的话,我们在shell中写交互的菜单程序是比较麻烦的。它的语法结构是:

select name [ in word ] ; do list ; done

还是来看看例子:

#!/bin/bash

select i in a b c d
do
    echo $i
done

这个程序执行的效果是:

[zorro@zorrozou-pc0 bash]$ ./select.sh 
1) a
2) b
3) c
4) d
#? 

你会发现select给你构造了一个交互菜单,索引为1,2,3,4。对应的名字就是程序中的a,b,c,d。之后我们就可以在后面输入相应的数字索引,选择要echo的内容:

[zorro@zorrozou-pc0 bash]$ ./select.sh 
1) a
2) b
3) c
4) d
#? 1
a
#? 2
b
#? 3
c
#? 4
d
#? 6

#? 
1) a
2) b
3) c
4) d
#? 
1) a
2) b
3) c
4) d
#? 

如果输入的不是菜单描述的范围就会echo一个空行,如果直接输入回车,就会再显示一遍菜单本身。当然我们会发现这样一个菜单程序似乎没有什么意义,实际程序中,select大多数情况是跟case配合使用的。

#!/bin/bash

select i in a b c d
do
    case $i in
        a)
        echo "Your choice is a"
        ;;
        b)
        echo "Your choice is b"
        ;;
        c)
        echo "Your choice is c"
        ;;
        d)
        echo "Your choice is d"
        ;;
        *)
        echo "Wrong choice! exit!"
        exit
        ;;
    esac
done

执行结果为:

[zorro@zorrozou-pc0 bash]$ ./select.sh 
1) a
2) b
3) c
4) d
#? 1
Your choice is a
#? 2
Your choice is b
#? 3
Your choice is c
#? 4
Your choice is d
#? 5
Wrong choice! exit!

这就是select的常见用法。

continue和break

对于bash的实现来说,continue和break实际上并不是语法的关键字,而是被作为内建命令来实现的。不过我们从习惯上依然把它们看作是bash的语法。在bash中,break和continue可以用来跳出和金星下一次for,while,until和select循环。

最后

我们在本文中介绍了bash编程的常用语法结构:if、while、until、case、两种for和select。我们在详细分析它们语法的特点的过程中,也简单说明了使用时需要注意的问题。希望这些知识和经验对大家以后在bash编程上有帮助。

通过bash编程语法的入门,我们也能发现,bash编程是一个上手容易,但是精通困难的编程语言。任何人想要写个简单的脚本,掌握几个语法结构和几个shell命令基本就可以干活了,但是想写出高质量的代码确没那么容易。通过语法的入门,我们可以管中窥豹的发现,讲述的过程中有无数个可以深入探讨的细节知识点,比如通配符、正则表达式、bash的特殊字符、bash的特殊属性和很多shell命令的使用。我们的后续文章会给大家分块整理这些知识点,如果你有兴趣,请持续关注。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716


大家好,我是Zorro!

如果你喜欢本文,欢迎在微博上搜索“orroz”关注我,地址是:http://weibo.com/orroz

大家也可以在微信上搜索:Linux系统技术 关注我的公众号。

我的所有文章都会沉淀在我的个人博客上,地址是:http://liwei.life。

欢迎使用以上各种方式一起探讨学习,共同进步。

公众号二维码:

Zorro] icon


 

Linux内存中的Cache真的能被回收么?

Linux内存中的Cache真的能被回收么?

在Linux系统中,我们经常用free命令来查看系统内存的使用状态。在一个RHEL6的系统上,free命令的显示内容大概是这样一个状态:

[root@tencent64 ~]# free
             total       used       free     shared    buffers     cached
Mem:     132256952   72571772   59685180          0    1762632   53034704
-/+ buffers/cache:   17774436  114482516
Swap:      2101192        508    2100684

这里的默认显示单位是kb,我的服务器是128G内存,所以数字显得比较大。这个命令几乎是每一个使用过Linux的人必会的命令,但越是这样的命令,似乎真正明白的人越少(我是说比例越少)。一般情况下,对此命令输出的理解可以分这几个层次:

  1. 不了解。这样的人的第一反应是:天啊,内存用了好多,70个多G,可是我几乎没有运行什么大程序啊?为什么会这样?Linux好占内存!
  2. 自以为很了解。这样的人一般评估过会说:嗯,根据我专业的眼光看的出来,内存才用了17G左右,还有很多剩余内存可用。buffers/cache占用的较多,说明系统中有进程曾经读写过文件,但是不要紧,这部分内存是当空闲来用的。
  3. 真的很了解。这种人的反应反而让人感觉最不懂Linux,他们的反应是:free显示的是这样,好吧我知道了。神马?你问我这些内存够不够,我当然不知道啦!我特么怎么知道你程序怎么写的?

根据目前网络上技术文档的内容,我相信绝大多数了解一点Linux的人应该处在第二种层次。大家普遍认为,buffers和cached所占用的内存空间是可以在内存压力较大的时候被释放当做空闲空间用的。但真的是这样么?在论证这个题目之前,我们先简要介绍一下buffers和cached是什么意思:


您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716


什么是buffer/cache?

buffer和cache是两个在计算机技术中被用滥的名词,放在不通语境下会有不同的意义。在Linux的内存管理中,这里的buffer指Linux内存的:Buffer cache。这里的cache指Linux内存中的:Page cache。翻译成中文可以叫做缓冲区缓存和页面缓存。在历史上,它们一个(buffer)被用来当成对io设备写的缓存,而另一个(cache)被用来当作对io设备的读缓存,这里的io设备,主要指的是块设备文件和文件系统上的普通文件。但是现在,它们的意义已经不一样了。在当前的内核中,page cache顾名思义就是针对内存页的缓存,说白了就是,如果有内存是以page进行分配管理的,都可以使用page cache作为其缓存来管理使用。当然,不是所有的内存都是以页(page)进行管理的,也有很多是针对块(block)进行管理的,这部分内存使用如果要用到cache功能,则都集中到buffer cache中来使用。(从这个角度出发,是不是buffer cache改名叫做block cache更好?)然而,也不是所有块(block)都有固定长度,系统上块的长度主要是根据所使用的块设备决定的,而页长度在X86上无论是32位还是64位都是4k。

明白了这两套缓存系统的区别,就可以理解它们究竟都可以用来做什么了。

什么是page cache

Page cache主要用来作为文件系统上的文件数据的缓存来用,尤其是针对当进程对文件有read/write操作的时候。如果你仔细想想的话,作为可以映射文件到内存的系统调用:mmap是不是很自然的也应该用到page cache?在当前的系统实现里,page cache也被作为其它文件类型的缓存设备来用,所以事实上page cache也负责了大部分的块设备文件的缓存工作。

什么是buffer cache

Buffer cache则主要是设计用来在系统对块设备进行读写的时候,对块进行数据缓存的系统来使用。这意味着某些对块的操作会使用buffer cache进行缓存,比如我们在格式化文件系统的时候。一般情况下两个缓存系统是一起配合使用的,比如当我们对一个文件进行写操作的时候,page cache的内容会被改变,而buffer cache则可以用来将page标记为不同的缓冲区,并记录是哪一个缓冲区被修改了。这样,内核在后续执行脏数据的回写(writeback)时,就不用将整个page写回,而只需要写回修改的部分即可。

如何回收cache?

Linux内核会在内存将要耗尽的时候,触发内存回收的工作,以便释放出内存给急需内存的进程使用。一般情况下,这个操作中主要的内存释放都来自于对buffer/cache的释放。尤其是被使用更多的cache空间。既然它主要用来做缓存,只是在内存够用的时候加快进程对文件的读写速度,那么在内存压力较大的情况下,当然有必要清空释放cache,作为free空间分给相关进程使用。所以一般情况下,我们认为buffer/cache空间可以被释放,这个理解是正确的。

但是这种清缓存的工作也并不是没有成本。理解cache是干什么的就可以明白清缓存必须保证cache中的数据跟对应文件中的数据一致,才能对cache进行释放。所以伴随着cache清除的行为的,一般都是系统IO飙高。因为内核要对比cache中的数据和对应硬盘文件上的数据是否一致,如果不一致需要写回,之后才能回收。

在系统中除了内存将被耗尽的时候可以清缓存以外,我们还可以使用下面这个文件来人工触发缓存清除的操作:

[root@tencent64 ~]# cat /proc/sys/vm/drop_caches 
1

方法是:

echo 1 > /proc/sys/vm/drop_caches

当然,这个文件可以设置的值分别为1、2、3。它们所表示的含义为:
echo 1 > /proc/sys/vm/drop_caches:表示清除pagecache。

echo 2 > /proc/sys/vm/drop_caches:表示清除回收slab分配器中的对象(包括目录项缓存和inode缓存)。slab分配器是内核中管理内存的一种机制,其中很多缓存数据实现都是用的pagecache。

echo 3 > /proc/sys/vm/drop_caches:表示清除pagecache和slab分配器中的缓存对象。

cache都能被回收么?

我们分析了cache能被回收的情况,那么有没有不能被回收的cache呢?当然有。我们先来看第一种情况:

tmpfs

大家知道Linux提供一种“临时”文件系统叫做tmpfs,它可以将内存的一部分空间拿来当做文件系统使用,使内存空间可以当做目录文件来用。现在绝大多数Linux系统都有一个叫做/dev/shm的tmpfs目录,就是这样一种存在。当然,我们也可以手工创建一个自己的tmpfs,方法如下:

[root@tencent64 ~]# mkdir /tmp/tmpfs
[root@tencent64 ~]# mount -t tmpfs -o size=20G none /tmp/tmpfs/

[root@tencent64 ~]# df
Filesystem           1K-blocks      Used Available Use% Mounted on
/dev/sda1             10325000   3529604   6270916  37% /
/dev/sda3             20646064   9595940  10001360  49% /usr/local
/dev/mapper/vg-data  103212320  26244284  71725156  27% /data
tmpfs                 66128476  14709004  51419472  23% /dev/shm
none                  20971520         0  20971520   0% /tmp/tmpfs

于是我们就创建了一个新的tmpfs,空间是20G,我们可以在/tmp/tmpfs中创建一个20G以内的文件。如果我们创建的文件实际占用的空间是内存的话,那么这些数据应该占用内存空间的什么部分呢?根据pagecache的实现功能可以理解,既然是某种文件系统,那么自然该使用pagecache的空间来管理。我们试试是不是这样?

[root@tencent64 ~]# free -g
             total       used       free     shared    buffers     cached
Mem:           126         36         89          0          1         19
-/+ buffers/cache:         15        111
Swap:            2          0          2
[root@tencent64 ~]# dd if=/dev/zero of=/tmp/tmpfs/testfile bs=1G count=13
13+0 records in
13+0 records out
13958643712 bytes (14 GB) copied, 9.49858 s, 1.5 GB/s
[root@tencent64 ~]# 
[root@tencent64 ~]# free -g
             total       used       free     shared    buffers     cached
Mem:           126         49         76          0          1         32
-/+ buffers/cache:         15        110
Swap:            2          0          2

我们在tmpfs目录下创建了一个13G的文件,并通过前后free命令的对比发现,cached增长了13G,说明这个文件确实放在了内存里并且内核使用的是cache作为存储。再看看我们关心的指标: -/+ buffers/cache那一行。我们发现,在这种情况下free命令仍然提示我们有110G内存可用,但是真的有这么多么?我们可以人工触发内存回收看看现在到底能回收多少内存:

[root@tencent64 ~]# echo 3 > /proc/sys/vm/drop_caches
[root@tencent64 ~]# free -g
             total       used       free     shared    buffers     cached
Mem:           126         43         82          0          0         29
-/+ buffers/cache:         14        111
Swap:            2          0          2

可以看到,cached占用的空间并没有像我们想象的那样完全被释放,其中13G的空间仍然被/tmp/tmpfs中的文件占用的。当然,我的系统中还有其他不可释放的cache占用着其余16G内存空间。那么tmpfs占用的cache空间什么时候会被释放呢?是在其文件被删除的时候.如果不删除文件,无论内存耗尽到什么程度,内核都不会自动帮你把tmpfs中的文件删除来释放cache空间。

[root@tencent64 ~]# rm /tmp/tmpfs/testfile 
[root@tencent64 ~]# free -g
             total       used       free     shared    buffers     cached
Mem:           126         30         95          0          0         16
-/+ buffers/cache:         14        111
Swap:            2          0          2

这是我们分析的第一种cache不能被回收的情况。还有其他情况,比如:

共享内存

共享内存是系统提供给我们的一种常用的进程间通信(IPC)方式,但是这种通信方式不能在shell中申请和使用,所以我们需要一个简单的测试程序,代码如下:

[root@tencent64 ~]# cat shm.c 

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <string.h>

#define MEMSIZE 2048*1024*1023

int
main()
{
    int shmid;
    char *ptr;
    pid_t pid;
    struct shmid_ds buf;
    int ret;

    shmid = shmget(IPC_PRIVATE, MEMSIZE, 0600);
    if (shmid<0) {
        perror("shmget()");
        exit(1);
    }

    ret = shmctl(shmid, IPC_STAT, &buf);
    if (ret < 0) {
        perror("shmctl()");
        exit(1);
    }

    printf("shmid: %d\n", shmid);
    printf("shmsize: %d\n", buf.shm_segsz);

    buf.shm_segsz *= 2;

    ret = shmctl(shmid, IPC_SET, &buf);
    if (ret < 0) {
        perror("shmctl()");
        exit(1);
    }

    ret = shmctl(shmid, IPC_SET, &buf);
    if (ret < 0) {
        perror("shmctl()");
        exit(1);
    }

    printf("shmid: %d\n", shmid);
    printf("shmsize: %d\n", buf.shm_segsz);


    pid = fork();
    if (pid<0) {
        perror("fork()");
        exit(1);
    }
    if (pid==0) {
        ptr = shmat(shmid, NULL, 0);
        if (ptr==(void*)-1) {
            perror("shmat()");
            exit(1);
        }
        bzero(ptr, MEMSIZE);
        strcpy(ptr, "Hello!");
        exit(0);
    } else {
        wait(NULL);
        ptr = shmat(shmid, NULL, 0);
        if (ptr==(void*)-1) {
            perror("shmat()");
            exit(1);
        }
        puts(ptr);
        exit(0);
    }
}

程序功能很简单,就是申请一段不到2G共享内存,然后打开一个子进程对这段共享内存做一个初始化操作,父进程等子进程初始化完之后输出一下共享内存的内容,然后退出。但是退出之前并没有删除这段共享内存。我们来看看这个程序执行前后的内存使用:

[root@tencent64 ~]# free -g
             total       used       free     shared    buffers     cached
Mem:           126         30         95          0          0         16
-/+ buffers/cache:         14        111
Swap:            2          0          2
[root@tencent64 ~]# ./shm 
shmid: 294918
shmsize: 2145386496
shmid: 294918
shmsize: -4194304
Hello!
[root@tencent64 ~]# free -g
             total       used       free     shared    buffers     cached
Mem:           126         32         93          0          0         18
-/+ buffers/cache:         14        111
Swap:            2          0          2

cached空间由16G涨到了18G。那么这段cache能被回收么?继续测试:

[root@tencent64 ~]# echo 3 > /proc/sys/vm/drop_caches
[root@tencent64 ~]# free -g
             total       used       free     shared    buffers     cached
Mem:           126         32         93          0          0         18
-/+ buffers/cache:         14        111
Swap:            2          0          2

结果是仍然不可回收。大家可以观察到,这段共享内存即使没人使用,仍然会长期存放在cache中,直到其被删除。删除方法有两种,一种是程序中使用shmctl()去IPC_RMID,另一种是使用ipcrm命令。我们来删除试试:

[root@tencent64 ~]# ipcs -m

------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status      
0x00005feb 0          root       666        12000      4                       
0x00005fe7 32769      root       666        524288     2                       
0x00005fe8 65538      root       666        2097152    2                       
0x00038c0e 131075     root       777        2072       1                       
0x00038c14 163844     root       777        5603392    0                       
0x00038c09 196613     root       777        221248     0                       
0x00000000 294918     root       600        2145386496 0                       

[root@tencent64 ~]# ipcrm -m 294918
[root@tencent64 ~]# ipcs -m

------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status      
0x00005feb 0          root       666        12000      4                       
0x00005fe7 32769      root       666        524288     2                       
0x00005fe8 65538      root       666        2097152    2                       
0x00038c0e 131075     root       777        2072       1                       
0x00038c14 163844     root       777        5603392    0                       
0x00038c09 196613     root       777        221248     0                       

[root@tencent64 ~]# free -g
             total       used       free     shared    buffers     cached
Mem:           126         30         95          0          0         16
-/+ buffers/cache:         14        111
Swap:            2          0          2

删除共享内存后,cache被正常释放了。这个行为与tmpfs的逻辑类似。内核底层在实现共享内存(shm)、消息队列(msg)和信号量数组(sem)这些POSIX:XSI的IPC机制的内存存储时,使用的都是tmpfs。这也是为什么共享内存的操作逻辑与tmpfs类似的原因。当然,一般情况下是shm占用的内存更多,所以我们在此重点强调共享内存的使用。说到共享内存,Linux还给我们提供了另外一种共享内存的方法,就是:

mmap

mmap()是一个非常重要的系统调用,这仅从mmap本身的功能描述上是看不出来的。从字面上看,mmap就是将一个文件映射进进程的虚拟内存地址,之后就可以通过操作内存的方式对文件的内容进行操作。但是实际上这个调用的用途是很广泛的。当malloc申请内存时,小段内存内核使用sbrk处理,而大段内存就会使用mmap。当系统调用exec族函数执行时,因为其本质上是将一个可执行文件加载到内存执行,所以内核很自然的就可以使用mmap方式进行处理。我们在此仅仅考虑一种情况,就是使用mmap进行共享内存的申请时,会不会跟shmget()一样也使用cache?

同样,我们也需要一个简单的测试程序:

[root@tencent64 ~]# cat mmap.c 
#include <stdlib.h>
#include <stdio.h>
#include <strings.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>

#define MEMSIZE 1024*1024*1023*2
#define MPFILE "./mmapfile"

int main()
{
    void *ptr;
    int fd;

    fd = open(MPFILE, O_RDWR);
    if (fd < 0) {
        perror("open()");
        exit(1);
    }

    ptr = mmap(NULL, MEMSIZE, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANON, fd, 0);
    if (ptr == NULL) {
        perror("malloc()");
        exit(1);
    }

    printf("%p\n", ptr);
    bzero(ptr, MEMSIZE);

    sleep(100);

    munmap(ptr, MEMSIZE);
    close(fd);

    exit(1);
}

这次我们干脆不用什么父子进程的方式了,就一个进程,申请一段2G的mmap共享内存,然后初始化这段空间之后等待100秒,再解除影射所以我们需要在它sleep这100秒内检查我们的系统内存使用,看看它用的是什么空间?当然在这之前要先创建一个2G的文件./mmapfile。结果如下:

[root@tencent64 ~]# dd if=/dev/zero of=mmapfile bs=1G count=2
[root@tencent64 ~]# echo 3 > /proc/sys/vm/drop_caches
[root@tencent64 ~]# free -g
             total       used       free     shared    buffers     cached
Mem:           126         30         95          0          0         16
-/+ buffers/cache:         14        111
Swap:            2          0          2

然后执行测试程序:

[root@tencent64 ~]# ./mmap &
[1] 19157
0x7f1ae3635000
[root@tencent64 ~]# free -g
             total       used       free     shared    buffers     cached
Mem:           126         32         93          0          0         18
-/+ buffers/cache:         14        111
Swap:            2          0          2

[root@tencent64 ~]# echo 3 > /proc/sys/vm/drop_caches
[root@tencent64 ~]# free -g
             total       used       free     shared    buffers     cached
Mem:           126         32         93          0          0         18
-/+ buffers/cache:         14        111
Swap:            2          0          2

我们可以看到,在程序执行期间,cached一直为18G,比之前涨了2G,并且此时这段cache仍然无法被回收。然后我们等待100秒之后程序结束。

[root@tencent64 ~]# 
[1]+  Exit 1                  ./mmap
[root@tencent64 ~]# 
[root@tencent64 ~]# free -g
             total       used       free     shared    buffers     cached
Mem:           126         30         95          0          0         16
-/+ buffers/cache:         14        111
Swap:            2          0          2

程序退出之后,cached占用的空间被释放。这样我们可以看到,使用mmap申请标志状态为MAP_SHARED的内存,内核也是使用的cache进行存储的。在进程对相关内存没有释放之前,这段cache也是不能被正常释放的。实际上,mmap的MAP_SHARED方式申请的内存,在内核中也是由tmpfs实现的。由此我们也可以推测,由于共享库的只读部分在内存中都是以mmap的MAP_SHARED方式进行管理,实际上它们也都是要占用cache且无法被释放的。

最后

我们通过三个测试例子,发现Linux系统内存中的cache并不是在所有情况下都能被释放当做空闲空间用的。并且也也明确了,即使可以释放cache,也并不是对系统来说没有成本的。总结一下要点,我们应该记得这样几点:

  1. 当cache作为文件缓存被释放的时候会引发IO变高,这是cache加快文件访问速度所要付出的成本。
  2. tmpfs中存储的文件会占用cache空间,除非文件删除否则这个cache不会被自动释放。
  3. 使用shmget方式申请的共享内存会占用cache空间,除非共享内存被ipcrm或者使用shmctl去IPC_RMID,否则相关的cache空间都不会被自动释放。
  4. 使用mmap方法申请的MAP_SHARED标志的内存会占用cache空间,除非进程将这段内存munmap,否则相关的cache空间都不会被自动释放。
  5. 实际上shmget、mmap的共享内存,在内核层都是通过tmpfs实现的,tmpfs实现的存储用的都是cache。

当理解了这些的时候,希望大家对free命令的理解可以达到我们说的第三个层次。我们应该明白,内存的使用并不是简单的概念,cache也并不是真的可以当成空闲空间用的。如果我们要真正深刻理解你的系统上的内存到底使用的是否合理,是需要理解清楚很多更细节知识,并且对相关业务的实现做更细节判断的。我们当前实验场景是Centos 6的环境,不同版本的Linux的free现实的状态可能不一样,大家可以自己去找出不同的原因。

当然,本文所述的也不是所有的cache不能被释放的情形。那么,在你的应用场景下,还有那些cache不能被释放的场景呢?

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716


Hi,我是Zorro,我会不定期的分享一些技术文章。欢迎大家扫下面二维码或搜索:Linux系统技术 关注我的公众号。

这是我的博客地址,欢迎来一起探讨。

我的微博地址,有兴趣可以来关注我呦。

Zorro] icon


 

Linux的进程优先级

 

Linux的进程优先级

Zorro] icon

Hi,我是Zorro。这是我的博客地址,我会不定期在这里更新文章,欢迎来一起探讨,
我的微博地址,有兴趣可以来关注我呦。

另外,我的其他联系方式:

Email: mini.jerry@gmail.com

QQ: 30007147

今天我们来谈谈:

Linux的进程优先级

为什么要有进程优先级?这似乎不用过多的解释,毕竟自从多任务操作系统诞生以来,进程执行占用cpu的能力就是一个必须要可以人为控制的事情。因为有的进程相对重要,而有的进程则没那么重要。进程优先级起作用的方式从发明以来基本没有什么变化,无论是只有一个cpu的时代,还是多核cpu时代,都是通过控制进程占用cpu时间的长短来实现的。就是说在同一个调度周期中,优先级高的进程占用的时间长些,而优先级低的进程占用的短些。从这个角度看,进程优先级其实也跟cgroup的cpu限制一样,都是一种针对cpu占用的QOS机制。我曾经一直很困惑一点,为什么已经有了优先级,还要再设计一个针对cpu的cgroup?得到的答案大概是因为,优先级这个值不能很直观的反馈出资源分配的比例吧?不过这不重要,实际上从内核目前的进程调度器cfs的角度说,同时实现cpushare方式的cgroup和优先级这两个机制完全是相同的概念,并不会因为增加一个机制而提高什么实现成本。既然如此,而cgroup又显得那么酷,那么何乐而不为呢?


您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716


再系统上我们最熟悉的优先级设置方式是nice喝renice命令。那么我们首先解释一个概念,什么是:

NICE值

nice值应该是熟悉Linux/UNIX的人很了解的概念了,我们都知它是反应一个进程“优先级”状态的值,其取值范围是-20至19,一共40个级别。这个值越小,表示进程”优先级”越高,而值越大“优先级”越低。我们可以通过nice命令来对一个将要执行的命令进行nice值设置,方法是:

[root@zorrozou-pc0 zorro]# nice -n 10 bash

这样我就又打开了一个bash,并且其nice值设置为10,而默认情况下,进程的优先级应该是从父进程继承来的,这个值一般是0。我们可以通过nice命令直接查看到当前shell的nice值

[root@zorrozou-pc0 zorro]# nice
10

对比一下正常情况:

[root@zorrozou-pc0 zorro]# exit

推出当前nice值为10的bash,打开一个正常的bash:

[root@zorrozou-pc0 zorro]# bash
[root@zorrozou-pc0 zorro]# nice
0

另外,使用renice命令可以对一个正在运行的进程进行nice值的调整,我们也可以使用比如top、ps等命令查看进程的nice值,具体方法我就不多说了,大家可以参阅相关manpage。

需要大家注意的是,我在这里都在使用nice值这一称谓,而非优先级(priority)这个说法。当然,nice和renice的man手册中,也说的是priority这个概念,但是要强调一下,请大家真的不要混淆了系统中的这两个概念,一个是nice值,一个是priority值,他们有着千丝万缕的关系,但对于当前的Linux系统来说,它们并不是同一个概念。

我们看这个命令:

[root@zorrozou-pc0 zorro]# ps -l
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
4 S     0  6924  5776  0  80   0 - 17952 poll_s pts/5    00:00:00 sudo
4 S     0  6925  6924  0  80   0 -  4435 wait   pts/5    00:00:00 bash
0 R     0 12971  6925  0  80   0 -  8514 -      pts/5    00:00:00 ps

大家是否真的明白其中PRI列和NI列的具体含义有什么区别?同样的,如果是top命令:

Tasks: 1587 total,   7 running, 1570 sleeping,   0 stopped,  10 zombie
Cpu(s): 13.0%us,  6.9%sy,  0.0%ni, 78.6%id,  0.0%wa,  0.0%hi,  1.5%si,  0.0%st
Mem:  132256952k total, 107483920k used, 24773032k free,  2264772k buffers
Swap:  2101192k total,      508k used,  2100684k free, 88594404k cached

  PID USER      PR  NI  VIRT  RES  SHR S %CPU %MEM    TIME+  COMMAND                                                                                                                                                                                                                                                                          
 3001 root      20   0  232m  21m 4500 S 12.9  0.0   0:15.09 python                                                                                                                                                                                                                                                                                
11541 root      20   0 17456 2400  888 R  7.4  0.0   0:00.06 top     

大家是否搞清楚了这其中PR值和NI值的差别?如果没有,那么我们可以首先搞清楚什么是nice值。

nice值虽然不是priority,但是它确实可以影响进程的优先级。在英语中,如果我们形容一个人nice,那一般说明这个人的人缘比较好。什么样的人人缘好?往往是谦让、有礼貌的人。比如,你跟一个nice的人一起去吃午饭,点了两个一样的饭,先上了一份后,nice的那位一般都会说:“你先吃你先吃!”,这就是人缘好,这人nice!但是如果另一份上的很晚,那么这位nice的人就要饿着了。这说明什么?越nice的人抢占资源的能力就越差,而越不nice的人抢占能力就越强。这就是nice值大小的含义,nice值越低,说明进程越不nice,抢占cpu的能力就越强,优先级就越高。在原来使用O1调度的Linux上,我们还会把nice值叫做静态优先级,这也基本符合nice值的特点,就是当nice值设定好了之后,除非我们用renice去改它,否则它是不变的。而priority的值在之前内核的O1调度器上表现是会变化的,所以也叫做动态优先级。

优先级和实时进程

简单了解nice值的概念之后,我们再来看看什么是priority值,就是ps命令中看到的PRI值或者top命令中看到的PR值。本文为了区分这些概念,以后统一用nice值表示NI值,或者叫做静态优先级,也就是用nice和renice命令来调整的优先级;而实用priority值表示PRI和PR值,或者叫动态优先级。我们也统一将“优先级”这个词的概念规定为表示priority值的意思。

在内核中,进程优先级的取值范围是通过一个宏定义的,这个宏的名称是MAX_PRIO,它的值为140。而这个值又是由另外两个值相加组成的,一个是代表nice值取值范围的NICE_WIDTH宏,另一个是代表实时进程(realtime)优先级范围的MAX_RT_PRIO宏。说白了就是,Linux实际上实现了140个优先级范围,取值范围是从0-139,这个值越小,优先级越高。nice值的-20到19,映射到实际的优先级范围是100-139。新产生进程的默认优先级被定义为:

#define DEFAULT_PRIO            (MAX_RT_PRIO + NICE_WIDTH / 2)

实际上对应的就是nice值的0。正常情况下,任何一个进程的优先级都是这个值,即使我们通过nice和renice命令调整了进程的优先级,它的取值范围也不会超出100-139的范围,除非这个进程是一个实时进程,那么它的优先级取值才会变成0-99这个范围中的一个。这里隐含了一个信息,就是说当前的Linux是一种已经支持实时进程的操作系统。

什么是实时操作系统,我们就不再这里详细解释其含义以及在工业领域的应用了,有兴趣的可以参考一下实时操作系统的维基百科。简单来说,实时操作系统需要保证相关的实时进程在较短的时间内响应,不会有较长的延时,并且要求最小的中断延时和进程切换延时。对于这样的需求,一般的进程调度算法,无论是O1还是CFS都是无法满足的,所以内核在设计的时候,将实时进程单独映射了100个优先级,这些优先级都要高与正常进程的优先级(nice值),而实时进程的调度算法也不同,它们采用更简单的调度算法来减少调度开销。总的来说,Linux系统中运行的进程可以分成两类:

  1. 实时进程
  2. 非实时进程

它们的主要区别就是通过优先级来区分的。所有优先级值在0-99范围内的,都是实时进程,所以这个优先级范围也可以叫做实时进程优先级,而100-139范围内的是非实时进程。在系统中可以使用chrt命令来查看、设置一个进程的实时优先级状态。我们可以先来看一下chrt命令的使用:

[root@zorrozou-pc0 zorro]# chrt
Show or change the real-time scheduling attributes of a process.

Set policy:
 chrt [options] <priority> <command> [<arg>...]
 chrt [options] -p <priority> <pid>

Get policy:
 chrt [options] -p <pid>

Policy options:
 -b, --batch          set policy to SCHED_OTHER
 -f, --fifo           set policy to SCHED_FIFO
 -i, --idle           set policy to SCHED_IDLE
 -o, --other          set policy to SCHED_OTHER
 -r, --rr             set policy to SCHED_RR (default)

Scheduling flag:
 -R, --reset-on-fork  set SCHED_RESET_ON_FORK for FIFO or RR

Other options:
 -a, --all-tasks      operate on all the tasks (threads) for a given pid
 -m, --max            show min and max valid priorities
 -p, --pid            operate on existing given pid
 -v, --verbose        display status information

 -h, --help     display this help and exit
 -V, --version  output version information and exit

For more details see chrt(1).

我们先来关注显示出的Policy options部分,会发现系统给个种进程提供了5种调度策略。但是这里并没有说明的是,这五种调度策略是分别给两种进程用的,对于实时进程可以用的调度策略是:SCHED_FIFO、SCHED_RR,而对于非实时进程则是:SCHED_OTHER、SCHED_OTHER、SCHED_IDLE。

系统的整体优先级策略是:如果系统中存在需要执行的实时进程,则优先执行实时进程。直到实时进程退出或者主动让出CPU时,才会调度执行非实时进程。实时进程可以指定的优先级范围为1-99,将一个要执行的程序以实时方式执行的方法为:

[root@zorrozou-pc0 zorro]# chrt 10 bash
[root@zorrozou-pc0 zorro]# chrt -p $$
pid 14840's current scheduling policy: SCHED_RR
pid 14840's current scheduling priority: 10

可以看到,新打开的bash已经是实时进程,默认调度策略为SCHED_RR,优先级为10。如果想修改调度策略,就加个参数:

[root@zorrozou-pc0 zorro]# chrt -f 10 bash
[root@zorrozou-pc0 zorro]# chrt -p $$
pid 14843's current scheduling policy: SCHED_FIFO
pid 14843's current scheduling priority: 10

刚才说过,SCHED_RR和SCHED_FIFO都是实时调度策略,只能给实时进程设置。对于所有实时进程来说,优先级高的(就是priority数字小的)进程一定会保证先于优先级低的进程执行。SCHED_RR和SCHED_FIFO的调度策略只有当两个实时进程的优先级一样的时候才会发生作用,其区别也是顾名思义:

SCHED_FIFO:以先进先出的队列方式进行调度,在优先级一样的情况下,谁先执行的就先调度谁,除非它退出或者主动释放CPU。

SCHED_RR:以时间片轮转的方式对相同优先级的多个进程进行处理。时间片长度为100ms。

这就是Linux对于实时进程的优先级和相关调度算法的描述。整体很简单,也很实用。而相对更麻烦的是非实时进程,它们才是Linux上进程的主要分类。对于非实时进程优先级的处理,我们首先还是要来介绍一下它们相关的调度算法:O1和CFS。

O1调度

O1调度算法是在Linux 2.6开始引入的,到Linux 2.6.23之后内核将调度算法替换成了CFS。虽然O1算法已经不是当前内核所默认使用的调度算法了,但是由于大量线上的服务器可能使用的Linux版本还是老版本,所以我相信很多服务器还是在使用着O1调度器,那么费一点口舌简单交代一下这个调度器也是有意义的。这个调度器的名字之所以叫做O1,主要是因为其算法的时间复杂度是O1。

O1调度器仍然是根据经典的时间片分配的思路来进行整体设计的。简单来说,时间片的思路就是将CPU的执行时间分成一小段一小段的,假如是5ms一段。于是多个进程如果要“同时”执行,实际上就是每个进程轮流占用5ms的cpu时间,而从1s的时间尺度上看,这些进程就是在“同时”执行的。当然,对于多核系统来说,就是把每个核心都这样做就行了。而在这种情况下,如何支持优先级呢?实际上就是将时间片分配成大小不等的若干种,优先级高的进程使用大的时间片,优先级小的进程使用小的时间片。这样在一个周期结速后,优先级大的进程就会占用更多的时间而因此得到特殊待遇。O1算法还有一个比较特殊的地方是,即使是相同的nice值的进程,也会再根据其CPU的占用情况将其分成两种类型:CPU消耗型和IO消耗性。典型的CPU消耗型的进程的特点是,它总是要一直占用CPU进行运算,分给它的时间片总是会被耗尽之后,程序才可能发生调度。比如常见的各种算数运算程序。而IO消耗型的特点是,它经常时间片没有耗尽就自己主动先释放CPU了,比如vi,emacs这样的编辑器就是典型的IO消耗型进程。

为什么要这样区分呢?因为IO消耗型的进程经常是跟人交互的进程,比如shell、编辑器等。当系统中既有这种进程,又有CPU消耗型进程存在,并且其nice值一样时,假设给它们分的时间片长度是一样的,都是500ms,那么人的操作可能会因为CPU消耗型的进程一直占用CPU而变的卡顿。可以想象,当bash在等待人输入的时候,是不占CPU的,此时CPU消耗的程序会一直运算,假设每次都分到500ms的时间片,此时人在bash上敲入一个字符的时候,那么bash很可能要等个几百ms才能给出响应,因为在人敲入字符的时候,别的进程的时间片很可能并没有耗尽,所以系统不会调度bash程度进行处理。为了提高IO消耗型进程的响应速度,系统将区分这两类进程,并动态调整CPU消耗的进程将其优先级降低,而IO消耗型的将其优先级变高,以降低CPU消耗进程的时间片的实际长度。已知nice值的范围是-20-19,其对应priority值的范围是100-139,对于一个默认nice值为0的进程来说,其初始priority值应该是120,随着其不断执行,内核会观察进程的CPU消耗状态,并动态调整priority值,可调整的范围是+-5。就是说,最高其优先级可以呗自动调整到115,最低到125。这也是为什么nice值叫做静态优先级而priority值叫做动态优先级的原因。不过这个动态调整的功能在调度器换成CFS之后就不需要了,因为CFS换了另外一种CPU时间分配方式,这个我们后面再说。

再简单了解了O1算法按时间片分配CPU的思路之后,我们再来结合进程的状态简单看看其算法描述。我们都知道进程有5种状态:

S(Interruptible sleep):可中断休眠状态。

D(Uninterruptible sleep):不可中断休眠状态。

R(Running or runnable):执行或者在可执行队列中。

Z(Zombie process):僵尸。

T(Stopped):暂停。

在CPU调度时,主要只关心R状态进程,因为其他状态进程并不会被放倒调度队列中进行调度。调度队列中的进程一般主要有两种情况,一种是进程已经被调度到CPU上执行,另一种是进程正在等待被调度。出现这两种状态的原因应该好理解,因为需要执行的进程数可能多于硬件的CPU核心数,比如需要执行的进程有8个而CPU核心只有4个,此时cpu满载的时候,一定会有4个进程处在“等待”状态,因为此时有另外四个进程正在占用CPU执行。

根据以上情况我们可以理解,系统当下需要同时进行调度处理的进程数(R状态进程数)和系统CPU的比值,可以一定程度的反应系统的“繁忙”程度。需要调度的进程越多,核心越少,则意味着系统越繁忙。除了进程执行本身需要占用CPU以外,多个进程的调度切换也会让系统繁忙程度增加的更多。所以,我们往往会发现,R状态进程数量在增长的情况下,系统的性能表现会下降。系统中可以使用uptime命令查看系统平均负载指数(load average):

[zorro@zorrozou-pc0 ~]$ uptime 
 16:40:56 up  2:12,  1 user,  load average: 0.05, 0.11, 0.16

其中load average中分别显示的是1分钟,5分钟,15分钟之内的平均负载指数(可以简单认为是相映时间范围内的R状态进程个数)。但是这个命令显示的数字是绝对个数,并没有表示出不同CPU核心数的实际情况。比如,如果我们的1分钟load average为16,而CPU核心数为32的话,那么这个系统的其实并不繁忙。但是如果CPU个数是8的话,那可能就意味着比较忙了。但是实际情况往往可能比这更加复杂,比如进程消耗类型也会对这个数字的解读有影响。总之,这个值的绝对高低并不能直观的反馈出来当前系统的繁忙程度,还需要根据系统的其它指标综合考虑。

O1调度器在处理流程上大概是这样进行调度的:

  1. 首先,进程产生(fork)的时候会给一个进程分配一个时间片长度。这个新进程的时间片一般是父进程的一半,而父进程也会因此减少它的时间片长度为原来的一半。就是说,如果一个进程产生了子进程,那么它们将会平分当前时间片长度。比如,如果父进程时间片还剩100ms,那么一个fork产生一个子进程之后,子进程的时间片是50ms,父进程剩余的时间片是也是50ms。这样设计的目的是,为了防止进程通过fork的方式让自己所处理的任务一直有时间片。不过这样做也会带来少许的不公平,因为先产生的子进程获得的时间片将会比后产生的长,第一个子进程分到父进程的一半,那么第二个子进程就只能分到1/4。对于一个长期工作的进程组来说,这种影响可以忽略,因为第一轮时间片在耗尽后,系统会在给它们分配长度相当的时间片。
  2. 针对所有R状态进程,O1算法使用两个队列组织进程,其中一个叫做活动队列,另一个叫做过期队列。活动队列中放的都是时间片未被耗尽的进程,而过期队列中放时间片被耗尽的进程。
  3. 如1所述,新产生的进程都会先获得一个时间片,进入活动队列等待调度到CPU执行。而内核会在每个tick间隔期间对正在CPU上执行的进程进行检查。一般的tick间隔时间就是cpu时钟中断间隔,每秒钟会有1000个,即频率为1000HZ。每个tick间隔周期主要检查两个内容:1、当前正在占用CPU的进程是不是时间片已经耗尽了?2、是不是有更高优先级的进程在活动队列中等待调度?如果任何一种情况成立,就把则当前进程的执行状态终止,放到等待队列中,换当前在等待队列中优先级最高的那个进程执行。

以上就是O1调度的基本调度思路,当然实际情况是,还要加上SMP(对称多处理)的逻辑,以满足多核CPU的需求。目前在我的archlinux上可以用以下命令查看内核HZ的配置:

[zorro@zorrozou-pc0 ~]$ zgrep CONFIG_HZ /proc/config.gz 
# CONFIG_HZ_PERIODIC is not set
# CONFIG_HZ_100 is not set
# CONFIG_HZ_250 is not set
CONFIG_HZ_300=y
# CONFIG_HZ_1000 is not set
CONFIG_HZ=300

我们发现我当前系统的HZ配置为300,而不是一般情况下的1000。大家也可以思考一下,配置成不同的数字(100、250、300、1000),对系统的性能到底会有什么影响?

CFS完全公平调度

O1已经是上一代调度器了,由于其对多核、多CPU系统的支持性能并不好,并且内核功能上要加入cgroup等因素,Linux在2.6.23之后开始启用CFS作为对一般优先级(SCHED_OTHER)进程调度方法。在这个重新设计的调度器中,时间片,动态、静态优先级以及IO消耗,CPU消耗的概念都不再重要。CFS采用了一种全新的方式,对上述功能进行了比较完善的支持。

其设计的基本思路是,我们想要实现一个对所有进程完全公平的调度器。又是那个老问题:如何做到完全公平?答案跟上一篇IO调度中CFQ的思路类似:如果当前有n个进程需要调度执行,那么调度器应该再一个比较小的时间范围内,把这n个进程全都调度执行一遍,并且它们平分cpu时间,这样就可以做到所有进程的公平调度。那么这个比较小的时间就是任意一个R状态进程被调度的最大延时时间,即:任意一个R状态进程,都一定会在这个时间范围内被调度相应。这个时间也可以叫做调度周期,其英文名字叫做:sched_latency_ns。进程越多,每个进程在周期内被执行的时间就会被平分的越小。调度器只需要对所有进程维护一个累积占用CPU时间数,就可以衡量出每个进程目前占用的CPU时间总量是不是过大或者过小,这个数字记录在每个进程的vruntime中。所有待执行进程都以vruntime为key放到一个由红黑树组成的队列中,每次被调度执行的进程,都是这个红黑树的最左子树上的那个进程,即vruntime时间最少的进程,这样就保证了所有进程的相对公平。

在基本驱动机制上CFS跟O1一样,每次时钟中断来临的时候,都会进行队列调度检查,判断是否要进程调度。当然还有别的时机需要调度检查,发生调度的时机可以总结为这样几个:

  1. 当前进程的状态转换时。主要是指当前进程终止退出或者进程休眠的时候。
  2. 当前进程主动放弃CPU时。状态变为sleep也可以理解为主动放弃CPU,但是当前内核给了一个方法,可以使用sched_yield()在不发生状态切换的情况下主动让出CPU。
  3. 当前进程的vruntime时间大于每个进程的理想占用时间时(delta_exec > ideal_runtime)。这里的ideal_runtime实际上就是上文说的sched_latency_ns/进程数n。当然这个值并不是一定这样得出,下文会有更详细解释。
  4. 当进程从中断、异常或系统调用返回时,会发生调度检查。比如时钟中断。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716


CFS的优先级

当然,CFS中还需要支持优先级。在新的体系中,优先级是以时间消耗(vruntime增长)的快慢来决定的。就是说,对于CFS来说,衡量的时间累积的绝对值都是一样纪录在vruntime中的,但是不同优先级的进程时间增长的比率是不同的,高优先级进程时间增长的慢,低优先级时间增长的快。比如,优先级为19的进程,实际占用cpu为1秒,那么在vruntime中就记录1s。但是如果是-20优先级的进程,那么它很可能实际占CPU用10s,在vruntime中才会纪录1s。CFS真实实现的不同nice值的cpu消耗时间比例在内核中是按照“每差一级cpu占用时间差10%左右”这个原则来设定的。这里的大概意思是说,如果有两个nice值为0的进程同时占用cpu,那么它们应该每人占50%的cpu,如果将其中一个进程的nice值调整为1的话,那么此时应保证优先级高的进程比低的多占用10%的cpu,就是nice值为0的占55%,nice值为1的占45%。那么它们占用cpu时间的比例为55:45。这个值的比例约为1.25。就是说,相邻的两个nice值之间的cpu占用时间比例的差别应该大约为1.25。根据这个原则,内核对40个nice值做了时间计算比例的对应关系,它在内核中以一个数组存在:

static const int prio_to_weight[40] = {
 /* -20 */     88761,     71755,     56483,     46273,     36291,
 /* -15 */     29154,     23254,     18705,     14949,     11916,
 /* -10 */      9548,      7620,      6100,      4904,      3906,
 /*  -5 */      3121,      2501,      1991,      1586,      1277,
 /*   0 */      1024,       820,       655,       526,       423,
 /*   5 */       335,       272,       215,       172,       137,
 /*  10 */       110,        87,        70,        56,        45,
 /*  15 */        36,        29,        23,        18,        15,
};

我们看到,实际上nice值的最高优先级和最低优先级的时间比例差距还是很大的,绝不仅仅是例子中的十倍。由此我们也可以推导出每一个nice值级别计算vruntime的公式为:

delta vruntime = delta Time * 1024 / load

这个公式的意思是说,在nice值为0的时候(对应的比例值为1024),计算这个进程vruntime的实际增长时间值(delta vruntime)为:CPU占用时间(delta Time)* 1024 / load。在这个公式中load代表当前sched_entity的值,其实就可以理解为需要调度的进程(R状态进程)个数。load越大,那么每个进程所能分到的时间就越少。CPU调度是内核中会频繁进行处理的一个时间,于是上面的delta vruntime的运算会被频繁计算。除法运算会占用更多的cpu时间,所以内核编程中的一个原则就是,尽可能的不用除法。内核中要用除法的地方,基本都用乘法和位移运算来代替,所以上面这个公式就会变成:

delta vruntime = delta time * 1024 * (2^32 / (load * 2^32)) = (delta time * 1024 * Inverse(load)) >> 32

内核中为了方便不同nice值的Inverse(load)的相关计算,对做好了一个跟prio_to_weight数组一一对应的数组,在计算中可以直接拿来使用,减少计算时的CPU消耗:

static const u32 prio_to_wmult[40] = {
 /* -20 */     48388,     59856,     76040,     92818,    118348,
 /* -15 */    147320,    184698,    229616,    287308,    360437,
 /* -10 */    449829,    563644,    704093,    875809,   1099582,
 /*  -5 */   1376151,   1717300,   2157191,   2708050,   3363326,
 /*   0 */   4194304,   5237765,   6557202,   8165337,  10153587,
 /*   5 */  12820798,  15790321,  19976592,  24970740,  31350126,
 /*  10 */  39045157,  49367440,  61356676,  76695844,  95443717,
 /*  15 */ 119304647, 148102320, 186737708, 238609294, 286331153,
};

具体计算细节不在这里细解释了,有兴趣的可以自行阅读代码:kernel/shced/fair.c(Linux 4.4)中的__calc_delta()函数实现。

根据CFS的特性,我们知道调度器总是选择vruntime最小的进程进行调度。那么如果有两个进程的初始化vruntime时间一样时,一个进程被选择进行调度处理,那么只要一进行处理,它的vruntime时间就会大于另一个进程,CFS难道要马上换另一个进程处理么?出于减少频繁切换进程所带来的成本考虑,显然并不应该这样。CFS设计了一个sched_min_granularity_ns参数,用来设定进程被调度执行之后的最小CPU占用时间。

[zorro@zorrozou-pc0 ~]$ cat /proc/sys/kernel/sched_min_granularity_ns 
2250000

一个进程被调度执行后至少要被执行这么长时间才会发生调度切换。

我们知道无论到少个进程要执行,它们都有一个预期延迟时间,即:sched_latency_ns,系统中可以通过如下命令来查看这个时间:

[zorro@zorrozou-pc0 ~]$ cat /proc/sys/kernel/sched_latency_ns 
18000000

在这种情况下,如果需要调度的进程个数为n,那么平均每个进程占用的CPU时间为sched_latency_ns/n。显然,每个进程实际占用的CPU时间会因为n的增大而减小。但是实现上不可能让它无限的变小,所以sched_min_granularity_ns的值也限定了每个进程可以获得的执行时间周期的最小值。当进程很多,导致使用了sched_min_granularity_ns作为最小调度周期时,对应的调度延时也就不在遵循sched_latency_ns的限制,而是以实际的需要调度的进程个数n * sched_min_granularity_ns进行计算。当然,我们也可以把这理解为CFS的”时间片”,不过我们还是要强调,CFS是没有跟O1类似的“时间片“的概念的,具体区别大家可以自己琢磨一下。

新进程的vruntime值

CFS是通过vruntime最小值来选择需要调度的进程的,那么可以想象,在一个已经有多个进程执行了相对较长的系统中,这个队列中的vruntime时间纪录的数值都会比较长。如果新产生的进程直接将自己的vruntime值设置为0的话,那么它将在执行开始的时间内抢占很多的CPU时间,直到自己的vruntime追赶上其他进程后才可能调度其他进程,这种情况显然是不公平的。所以CFS对每个CPU的执行队列都维护一个min_vruntime值,这个值纪录了这个CPU执行队列中vruntime的最小值,当队列中出现一个新建的进程时,它的初始化vruntime将不会被设置为0,而是根据min_vruntime的值为基础来设置。这样就保证了新建进程的vruntime与老进程的差距在一定范围内,不会因为vruntime设置为0而在进程开始的时候占用过多的CPU。

新建进程获得的实际vruntime值跟一些设置有关,比如:

[zorro@zorrozou-pc0 ~]$ cat /proc/sys/kernel/sched_child_runs_first 
0

这个文件是fork之后是否让子进程优先于父进程执行的开关。0为关闭,1为打开。如果这个开关打开,就意味着子进程创建后,保证子进程在父进程之前被调度。另外,在源代码目录下的kernel/sched/features.h文件中,还规定了一系列调度器属性开关。而其中:

/*
 * Place new tasks ahead so that they do not starve already running
 * tasks
 */
SCHED_FEAT(START_DEBIT, true)

这个参数规定了新进程启动之后第一次运行会有延时。这意味着新进程的vruntime设置要比默认值大一些,这样做的目的是防止应用通过不停的fork来尽可能多的获得执行时间。子进程在创建的时候,vruntime的定义的步骤如下,首先vruntime被设置为min_vruntime。然后判断START_DEBIT位是否被值为true,如果是则会在min_vruntime的基础上增大一些,增大的时间实际上就是一个进程的调度延时时间,即上面描述过的calc_delta_fair()函数得到的结果。这个时间设置完毕之后,就检查sched_child_runs_first开关是否打开,如果打开(值被设置为1),就比较新进程的vruntime和父进程的vruntime哪个更小,并将新进程的vruntime设置为更小的那个值,而父进程的vruntime设置为更大的那个值,以此保证子进程一定在父进程之前被调度。

IO消耗型进程的处理

根据前文,我们知道除了可能会一直占用CPU时间的CPU消耗型进程以外,还有一类叫做IO消耗类型的进程,它们的特点是基本不占用CPU,主要行为是在S状态等待响应。这类进程典型的是vim,bash等跟人交互的进程,以及一些压力不大的,使用了多进程(线程)的或select、poll、epoll的网络代理程序。如果CFS采用默认的策略处理这些程序的话,相比CPU消耗程序来说,这些应用由于绝大多数时间都处在sleep状态,它们的vruntime时间基本是不变的,一旦它们进入了调度队列,将会很快被选择调度执行。对比O1调度算法,这种行为相当于自然的提高了这些IO消耗型进程的优先级,于是就不需要特殊对它们的优先级进行“动态调整”了。

但这样的默认策略也是有问题的,有时CPU消耗型和IO消耗型进程的区分不是那么明显,有些进程可能会等一会,然后调度之后也会长时间占用CPU。这种情况下,如果休眠的时候进程的vruntime保持不变,那么等到休眠被唤醒之后,这个进程的vruntime时间就可能会比别人小很多,从而导致不公平。所以对于这样的进程,CFS也会对其进行时间补偿。补偿方式为,如果进程是从sleep状态被唤醒的,而且GENTLE_FAIR_SLEEPERS属性的值为true,则vruntime被设置为sched_latency_ns的一半和当前进程的vruntime值中比较大的那个。sched_latency_ns的值可以在这个文件中进行设置:

[zorro@zorrozou-pc0 ~]$ cat /proc/sys/kernel/sched_latency_ns 
18000000

因为系统中这种调度补偿的存在,IO消耗型的进程总是可以更快的获得响应速度。这是CFS处理与人交互的进程时的策略,即:通过提高响应速度让人的操作感受更好。但是有时候也会因为这样的策略导致整体性能受损。在很多使用了多进程(线程)或select、poll、epoll的网络代理程序,一般是由多个进程组成的进程组进行工作,典型的如apche、nginx和php-fpm这样的处理程序。它们往往都是由一个或者多个进程使用nanosleep()进行周期性的检查是否有新任务,如果有责唤醒一个子进程进行处理,子进程的处理可能会消耗CPU,而父进程则主要是sleep等待唤醒。这个时候,由于系统对sleep进程的补偿策略的存在,新唤醒的进程就可能会打断正在处理的子进程的过程,抢占CPU进行处理。当这种打断很多很频繁的时候,CPU处理的过程就会因为频繁的进程上下文切换而变的很低效,从而使系统整体吞吐量下降。此时我们可以使用开关禁止唤醒抢占的特性。

[root@zorrozou-pc0 zorro]# cat /sys/kernel/debug/sched_features
GENTLE_FAIR_SLEEPERS START_DEBIT NO_NEXT_BUDDY LAST_BUDDY CACHE_HOT_BUDDY WAKEUP_PREEMPTION NO_HRTICK NO_DOUBLE_TICK LB_BIAS NONTASK_CAPACITY TTWU_QUEUE RT_PUSH_IPI NO_FORCE_SD_OVERLAP RT_RUNTIME_SHARE NO_LB_MIN ATTACH_AGE_LOAD 

上面显示的这个文件的内容就是系统中用来控制kernel/sched/features.h这个文件所列内容的开关文件,其中WAKEUP_PREEMPTION表示:目前的系统状态是打开sleep唤醒进程的抢占属性的。可以使用如下命令关闭这个属性:

[root@zorrozou-pc0 zorro]# echo NO_WAKEUP_PREEMPTION > /sys/kernel/debug/sched_features
[root@zorrozou-pc0 zorro]# cat /sys/kernel/debug/sched_features
GENTLE_FAIR_SLEEPERS START_DEBIT NO_NEXT_BUDDY LAST_BUDDY CACHE_HOT_BUDDY NO_WAKEUP_PREEMPTION NO_HRTICK NO_DOUBLE_TICK LB_BIAS NONTASK_CAPACITY TTWU_QUEUE RT_PUSH_IPI NO_FORCE_SD_OVERLAP RT_RUNTIME_SHARE NO_LB_MIN ATTACH_AGE_LOAD 

其他相关参数的调整也是类似这样的方式。其他我没讲到的属性的含义,大家可以看kernel/sched/features.h文件中的注释。

系统中还提供了一个sched_wakeup_granularity_ns配置文件,这个文件的值决定了唤醒进程是否可以抢占的一个时间粒度条件。默认CFS的调度策略是,如果唤醒的进程vruntime小于当前正在执行的进程,那么就会发生唤醒进程抢占的情况。而sched_wakeup_granularity_ns这个参数是说,只有在当前进程的vruntime时间减唤醒进程的vruntime时间所得的差大于sched_wakeup_granularity_ns时,才回发生抢占。就是说sched_wakeup_granularity_ns的值越大,越不容易发生抢占。

CFS和其他调度策略

SCHED_BATCH

在上文中我们说过,CFS调度策略主要是针对chrt命令显示的SCHED_OTHER范围的进程,实际上就是一般的非实时进程。我们也已经知道,这样的一般进程还包括另外两种:SCHED_BATCH和SCHED_IDLE。在CFS的实现中,集成了对SCHED_BATCH策略的支持,并且其功能和SCHED_OTHER策略几乎是一致的。唯一的区别在于,如果一个进程被用chrt命令标记成SCHED_OTHER策略的话,CFS将永远认为这个进程是CPU消耗型的进程,不会对其进行IO消耗进程的时间补偿。这样做的唯一目的是,可以在确认进程是CPU消耗型的进程的前提下,对其尽可能的进行批处理方式调度(batch),以减少进程切换带来的损耗,提高吞度量。实际上这个策略的作用并不大,内核中真正的处理区别只是在标记为SCHED_BATCH时进程在sched_yield主动让出cpu的行为发生是不去更新cfs的队列时间,这样就让这些进程在主动让出CPU的时候(执行sched_yield)不会纪录其vruntime的更新,从而可以继续优先被调度到。对于其他行为,并无不同。

SCHED_IDLE

如果一个进程被标记成了SCHED_IDLE策略,调度器将认为这个优先级是很低很低的,比nice值为19的优先级还要低。系统将只在CPU空闲的时候才会对这样的进程进行调度执行。若果存在多个这样的进程,它们之间的调度方式跟正常的CFS相同。

SCHED_DEADLINE

最新的Linux内核还实现了一个最新的调度方式叫做SCHED_DEADLINE。跟IO调度类似,这个算法也是要实现一个可以在最终期限到达前让进程可以调度执行的方法,保证进程不会饿死。目前大多数系统上的chrt还没给配置接口,暂且不做深入分析。

另外要注意的是,SCHED_BATCH和SCHED_IDLE一样,只能对静态优先级(即nice值)为0的进程设置。操作命令如下:

[zorro@zorrozou-pc0 ~]$ chrt -i 0 bash
[zorro@zorrozou-pc0 ~]$ chrt -p $$
pid 5478's current scheduling policy: SCHED_IDLE
pid 5478's current scheduling priority: 0

[zorro@zorrozou-pc0 ~]$ chrt -b 0 bash
[zorro@zorrozou-pc0 ~]$ chrt -p $$
pid 5502's current scheduling policy: SCHED_BATCH
pid 5502's current scheduling priority: 0

多CPU的CFS调度

在上面的叙述中,我们可以认为系统中只有一个CPU,那么相关的调度队列只有一个。实际情况是系统是有多核甚至多个CPU的,CFS从一开始就考虑了这种情况,它对每个CPU核心都维护一个调度队列,这样每个CPU都对自己的队列进程调度即可。这也是CFS比O1调度算法更高效的根本原因:每个CPU一个队列,就可以避免对全局队列使用大内核锁,从而提高了并行效率。当然,这样最直接的影响就是CPU之间的负载可能不均,为了维持CPU之间的负载均衡,CFS要定期对所有CPU进行load balance操作,于是就有可能发生进程在不同CPU的调度队列上切换的行为。这种操作的过程也需要对相关的CPU队列进行锁操作,从而降低了多个运行队列带来的并行性。不过总的来说,CFS的并行队列方式还是要比O1的全局队列方式要高效。尤其是在CPU核心越来越多的情况下,全局锁的效率下降显著增加。

CFS对多个CPU进行负载均衡的行为是idle_balance()函数实现的,这个函数会在CPU空闲的时候由schedule()进行调用,让空闲的CPU从其他繁忙的CPU队列中取进程来执行。我们可以通过查看/proc/sched_debug的信息来查看所有CPU的调度队列状态信息以及系统中所有进程的调度信息。内容较多,我就不在这里一一列出了,有兴趣的同学可以自己根据相关参考资料(最好的资料就是内核源码)了解其中显示的相关内容分别是什么意思。

在CFS对不同CPU的调度队列做均衡的时候,可能会将某个进程切换到另一个CPU上执行。此时,CFS会在将这个进程出队的时候将vruntime减去当前队列的min_vruntime,其差值作为结果会在入队另一个队列的时候再加上所入队列的min_vruntime,以此来保持队列切换后CPU队列的相对公平。

最后

本文的目的是从Linux系统进程的优先级为出发点,通过了解相关的知识点,希望大家对系统的进程调度有个整体的了解。其中我们也对CFS调度算法进行了比较深入的分析。在我的经验来看,这些知识对我们在观察系统的状态和相关优化的时候都是非常有用的。比如在使用top命令的时候,NI和PR值到底是什么意思?类似的地方还有ps命令中的NI和PRI值、ulimit命令-e和-r参数的区别等等。当然,希望看完本文后,能让大家对这些命令显示的了解更加深入。除此之外,我们还会发现,虽然top命令中的PR值和ps -l命令中的PRI值的含义是一样的,但是在优先级相同的情况下,它们显示的值确不一样。那么你知道为什么它们显示会有区别吗?这个问题的答案留给大家自己去寻找吧。


您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716


 

什么是Docker?

关于Docker是什么,有个著名的隐喻:集装箱。但是它却起了个“码头工人”(docker的英文翻译)的名字。这无疑给使用者很多暗示:“快来用吧!用了Docker,就像世界出现了集装箱,这样你的业务就可以随意的、无拘无束的运行在任何地方(Docker公司的口号:Build,Ship,and Run Any App,Anywhere),于是码头工人就基本都可以下岗了。”但是人们往往忽略了一个问题,隐喻的好处是方便人理解一个不容易理解的概念,但并不能解释其概念本身。

互联网技术行业的一大特点是,这里的绝大多数事物并不像现实生活那么具体,在这个行业中我们所接触的绝大多数概念都是抽象的、不具体的。所以现实生活中很多可笑的事情在互联网技术行业就不仅变的不可笑,反而可能很严肃。就比如,现实生活中你是几乎不可能看见两个神经正常的成年人争论到底是锤子更好还是螺丝刀更好这个问题的。而在我们这个行业,你可以很容易的被卷入到底是java好?还是php好?还是js好?或者类似的语言之争中。当然除了语言,其它的软件工具之争也比比皆是,比如经典的还有vim vs emacs。

由于不具体和抽象,就需要隐喻来给投资人解释其价值,毕竟投资人大多数是外行嘛。至于docker到底是“集装箱”还是“码头工人”并不重要,即使这两个概念本质上冲突了都不重要,很少有人会去真的思考集装箱的出现导致码头工人几乎绝迹。只要能让大家明白docker是个重要的、有价值的、划时代的工具,骗到投资人的钱就足够了。也很少有投资人去考究集装箱的发明人到底有没有因此赚到钱?以及为什么没赚到钱?只要概念能忽悠人就行了。当然这个概念顺便也忽悠了所有懒得思考的技术工程师。

吐了一大段槽之后,回到我们的正题,docker到底是什么?既然大家喜欢集装箱这个隐喻,那么我们也不妨先来看看集装箱的本质。大家应该基本都理解集装箱是怎么改变世界的吧?在集装箱之前,货物运输没有统一的标准方式进行搬运,于是铁路、公路、海洋等各种运输之间,需要大量的人力作为货物中转,效率极低,而且成本很高。集装箱出现之后,世界上绝大多数的货物运输都可以放到这个神奇的箱子里,然后在公路、铁路、海洋等所有运输场景下,这个箱子都可以不用变化形态直接可以承运,而且中间的中转工作,都可以通过大型机械搞定,效率大大提升。从此全球化开始,商业的潜力被进一步挖掘……牛逼之处我就不多说了,可是这个箱子为什么这么神奇呢?答案其实也就在上面的描述中,无非就是两个字:标准。

是的!标准!标准!标准!重要的事情说三遍。

因为规范了集装箱的大小和尺寸的规格标准,于是相应的船舶、卡车、列车才能按照规格制造出来使联运成为可能,所有的运输中转的自动化工具才能被设计建造出来并且高效的使用,才可以极大的提高效率,提升自动化水平,以至于码头工人才会失业。集装箱本身是一个产品,而这个产品无非就是“标准化”的这个概念穿上了马甲,马甲可以有红的、绿的、蓝的、花的,但是大小规格必须都一样。现实世界中的事实显而易见,就是这么简单。那么docker呢?

按照这个思路,docker其实跟集装箱一样,或者说它想跟集装箱一样,成为穿着马甲的“标准化”。这样开发工程师就可以把它们开发出来的bug们放到“集装箱”里,然后运维人员就可以使用标准化的操作工具去运维这些可爱的bug们。于是实现了“海陆联运”,就好像运维工程师根本不需要了解其运维的软件架构而开发工程师也并不需要了解其软件运行的操作系统一样……

这就是docker的实质:穿着马甲的标准化。docker的发明人根据自己运维PAAS平台的经验,重新思考了自己的工作,将PAAS平台的devops工作从各个角度标准化了一下,将系统底层实现的cgroup、namespace、aufs|device mapper等技术集成在一个使用镜像方式发布的工具中,于是形成了docker。观察docker形成的思考过程,其实就是作者针对他所运维的场景如何做自动化运维的思考,大家可以参见其演讲的ppt:http://www.slideshare.net/jpetazzo/docker-automation-for-the-rest-of-us?from_action=save。这个演讲的名字就跟自动化运维相关:Docker: automation for the rest of us 。那么Docker的实质是什么?在我看来就是个针对PAAS平台的自动化运维工具而已。众所周知(当然如果你不知道,那么我来告诉你):自动化运维的大前提就是标准化。

如果你正好是一个运维工程师而且你正感觉你的运维环境一团糟,麻烦请你思考一下这是为什么?你是不是正在运维着一个使用php、java、C#甚至C/C++等用各种语言编写的应用都在运行的环境里?这个环境是不是因为某种历史原因,使你的操作系统运行着各个版本的内核甚至还有windows?即使是同样语言编写的业务也运行着不同版本的库?你的整个系统环境是不是甚至找不出来两台硬件、操作系统、库版本以及语言版本完全一样的环境?于是你每次遇到问题都要去排查到底那个坑到底在那里?从网络、内核到应用逻辑。你每次遇到产品升级都要在各种环境上做稳定性测试,发现不同的环境代码crash的原因都不尽相同。你就像一个老中医一样去经历各种疑难杂症,如果遇到问题能找到原因甚至都是幸运的,绝大多数情况是解决了但不知道原因和没解决自动好了也不知道原因。于是你们在一个特定的公司的环境中积累着“经验”,成为你们组新手眼中的大神,凭借历经故障养成的条件反射在快速解决不断发生的重复问题,并故弄玄虚的说:这就是工作经验。因为经验经常是搞不清楚原因时的最后一个遮羞布。当别人抱怨你们部门效率低的时候,你一般的反应是:”you can you up,no can no 逼逼!“

我花了这么多口舌吐槽运维,无非就是想提醒大家”运维标准化的重要性“这一显而不易见的事实。标准化了,才能提高效率。标准化了,才能基于标准建设属于你们系统的自动化运维。那么我们再来看看docker是怎么做的?

首先,标准化就要有标准化的文档规范,要定义系统软件版本等一系列内容。规范好了之后,大家开始实施。但是在长期运维的过程中,很可能出现随着系统的发展,文档内容已经过时了,工程师又来不及更新文档的问题。怎么解决?docker给出的答案是:用dockerfile。dockerfile就是你的文档,并且用来产生镜像。要改变docker镜像中的环境,先改dockerfile,用它产生镜像就行了,保证文档和环境一致。那么现实是,有多少在使用docker的人是这样用的?你们是不是嫌这样麻烦,于是干脆直接在线docker commit产生镜像,让文档跟现场环境又不符了?或者我还是太理想,因为你们压根连文档都没有?

其次,标准化要有对应用统一操作的方法。在现实中,即使你用的是php开发的应用,启动的方式都可能不尽相同。有用apache的,有用nginx的,还有用某种不知名web容器的,甚至是自己开发web容器的。如果操作范围扩大到包含java等其它语言,或数据库等其它服务,那么操作方式更是千奇百怪。虽然UNIX操作系统早就对此作了统一的规范,就是大家常见的把启动脚本防盗/etc/rc.d中,SYSV标准中甚至规定了启动脚本该怎么写,应该有哪些方法。但是绝大多数人不知道,或者知道了也不这么做,他们习惯用./start作为自己业务启动的唯一标准。甚至./是哪个目录可能都记不住。于是docker又给出了解决方案:我压根不管你怎么启动,你自己爱咋来咋来,我们用docker start或run作为统一标准。于是docker start可以启动一个apache、nginx、jvm、mysql等等。有人病垢docker的设计,质疑它为什么设计上一个容器内只给启动一个进程?这就是原因:人家压根不是为了给你当虚拟机用的,人家是为了给启动生产环境的进程做标准化的!

第三,为了维护生产环境的一致性和配置变更的幂等,docker创造性的使用了类似git管理代码的方式对环境镜像进行管理。于是:
你想做库版本升级吗?更新个镜像吧!
你想做php、java的版本升级吗?更新个镜像吧。
好方便!太爽了!
等等……神马?你想改变apache配置文件中的一个字段?做个新镜像升级吧!
你的php代码写错了一行要改个bug?做个新镜像升级吧……
在一群人吐血三升之后,于是有人出了个主意。唉,其实后两种需求没必要这么麻烦,有一种软件叫做puppet、chef、salt、ansible、rsync……
于是我们要在docker中启动一个puppet。
什么?你要用ansible?好吧,我们来看看怎么在docker中启动一个sshd?
我有个计划任务要跑,起个crontab可以么?

你的docker是不是就这么变成了“虚拟机”的?

不过请注意:我并不是说docker不好,只是你是否真的评估了它标准话的方式是不是适合你的业务场景?锤子是用来砸钉子的,但是你非要用它来砸手指,我也没什么办法。

作为一个工程师,而且是受过专业训练的工程师,总是想设计出一套工具满足所有场景需求。因为工程师所受的思维训练是:你越是解决了更普遍的问题,你所创造的价值就越大。但是请搞清楚,这个任务一般是由标准委员会来完成的,每个工程行业都会有这么个组织来做这件事情。当然,不排除商业公司的产品可以深刻影响标准制定的情况。那么我们这些工程师最大的价值是什么?摆正自己的位置,看清自己的问题,帮组所在的企业进一步提高效率,提高竞争力。每个企业都有其历史和当前特点,就运维工作来讲,根据企业的实际情况找到其标准化的最经济有利方式才是我们这些受聘用的职业工程师的核心价值。软件选型要要因地制宜,而不是跟风炒作。当然,如果你的核心价值是想要站在“技术前沿”,打算一直引领技术潮流,做一个出没于各大技术交流会的新技术吹牛逼者,并以此抬高自己身价的话,那我的话自然是对你不适用的。(说这话可能会得罪很多人,我要解释一下:对于那些真诚的想要分享自己技术,希望为社区发展做贡献的人,我是怀着深深的敬意的!谢谢你们!)对待新技术,大多数工程师的状态是:测试是为了上线的,测试出的问题都是要解决的而不是用来评估的,不上线就没有工作成果。我认为工程师对待新技术应有得态度是:激进的用新技术新方法来做线下测试,认真的总结评估测试流程和结果和现有环境的异同,保守谨慎的评估决策新技术是否在业务上大规模使用。

docker是银弹么?真的能像集装箱那样改变世界么?我的看法当然不是。即使集装箱,也不能解决一些特殊的运输问题,比如大型飞机零部件的运输,或者小件零散商品的运输。如果说云计算行业真的要出现集装箱的话,那么首先这个行业要被几大云计算厂商瓜分完毕,市场成熟之后才有可能。为什么?因为让一个应用可以在任何地方跑的需求,主要应该来自云的用户,他们可能为了稳定性考虑既租用了阿某云,又租用了腾讯云(纯广告,自己所在的公司,所以请勿吐槽),还可能为了海外市场还用了某马逊云。这时用户会有需求说,我想要这些云的环境标准一致,以便我的应用可以在哪朵云上都能跑(Build,Ship,and Run Any App)。而现在,云计算市场刚刚起步,群雄逐鹿,正是差异化发展争夺用户的时候。出了云计算厂商外,其它公司的IT环境都不一样,标准化要求也就不可能一样。那么你觉得docker这个标准可能适合所有人么?

如果你用过了docker,并且还觉得它非常合适你的环境,那么我希望你能回答这几个问题:
你的docker是用docker file产生的镜像还是直接docker commit?
你的docker里面跑了多少个进程?
你的docker是当虚拟机用的么?
那么你用的是docker么?

最后,送大家一个段子,希望能博你一笑。

工程师:“嘿!有人发明了一个叫做集装箱的东西,这东西一定可以使运输成本大大下降!甚至改变世界!”
用户:“好兴奋!这东西可以运输我的波音787客机么?“
工程师:“额。。不能整个运,需要拆开再运,因为我们要符合集装箱的标准……”
用户:“那这东西可以运输我的空客380嘛?”
工程师:“额。。我们讨论的是同一件事情。”
用户:“不行是嘛?那不能改造一下集装箱让它可以运嘛?”
工程师:“额。。。这不仅仅是我们的问题,要到达运输目的地还要经过铁路,公路,他们可能也无法……”
用户:“真的不能改造集装箱么?可这东西是以后的发展方向啊!未来的世界都是应该是集装箱运输的!”
工程师:“额……”
老板:“嗯!这东西说不定真的是未来的发展方向!我们一定要实现用集装箱运输这些飞机!工程师们,你们赶紧去攻克这些技术难题,早日可以实现我们用户的特殊需求!让集装箱可以达到我们的业务要求!快去吧!加油啊!”
工程师:“额……”

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

我为什么一定要“咬文嚼字”

咬文嚼字,是一个成语,含贬义,用来形容过分地斟酌字句。多指死扣字眼而不注意精神实质。当然它还有一个近义词,叫做字斟句酌。当然,对比一下就会发现,字斟句酌就并不含贬义。好吧好吧,我又开始咬文嚼字了。

事情的起因是昨天跟一个同事讨论问题(实际上是我在理解他的需求)的时候,同事问我为什么这么咬文嚼字呢?关于这件事情,我们可以先从另一个故事说起:

《吕氏春秋》里有一段,讲孔子周游列国,曾因兵荒马乱,旅途困顿,三餐以野菜果腹,大家已七日没吃下一粒米饭。

一天,颜回好不容易要到了一些白米煮饭,饭快煮熟时,孔子看到颜回掀起锅盖,抓些白饭往嘴里塞,孔子当时装作没看见,也不去责问。

饭煮好后,颜回请孔子进食,孔子假装若有所思地说:我刚才梦到祖先来找我,我想把干净还没人吃过的米饭,先拿来祭祖先吧!

颜回顿时慌张起来说:不可以的,这锅饭我已先吃一口了,不可以祭祖先了。

孔子问:为什么?

颜回涨红脸,嗫嗫地说:刚才在煮饭时,不小心掉了些染灰在锅里,染灰的白饭丢了太可惜,只好抓起来先吃了,我不是故意把饭吃了。

孔子听了,恍然大悟,对自己的观察错误,反而愧疚,抱歉地说:我平常对颜回已最信任,但仍然还会怀疑他,可见我们内心是最难确定稳定的。弟子们大家记下这件事,要了解一个人,还真是不容易啊!

针对这个故事,原文的评论是这样说的:

“所谓知人难,相知相惜更难。逢事必从上下、左右、前后各个角度来认识辨知,我们主观的了解观察,只是真相的千分之一,单一角度判断,是不能达到全方位的观照的!

当你要对一个人下结论的时候,想想:真的你所看到的才是事实吗?还是你只从一个面,一个点,去观察一个人呢?

大多数的人根本不了解对方的立场与困难的时候,就已经给了对方下评语了,更何况是在有利益冲突下的场合。

现今的人们拥有高学历高知识,却往往过度仰赖高知识,而忘了让自己在智慧上成长。很多事信者恒信,不信者恒不信,要客观地跳出成见,才有机会接近真相。连孔圣人也会对自己最信任的弟子起疑心,更何况我们呢?

我们是不是也常常因为亲眼所见、亲耳所闻,对他人产生了某种印象,从而为他人打上某种标签呢?孔圣人可以当下就用智慧,轻易了解真相,消除误会,可是我们呢?

有多少人,因为自己的亲眼所见,尤其是亲密关系里,从此耿耿于怀,甚至怀恨在心……可悲的是,到死都不知道,其实是自己看错了

两个人交流时,其实是六个人在交流:你以为的你,你以为的他,真正的你;他以为的他,他以为的你,真正的他。你想,这里边会有多少误会,会有多少误解?你总在和你以为的他交流,你知道真正的他的想法吗?”

当然,这是一个比较典型的心灵鸡汤文,写的是一些比较典型的“真理”型感悟。但是其背后透露出来的信息确实值得分析的。我们真的要像原文评论说的那样要在“智慧上成长”,“客观地跳出成见,才有机会接近真相”么?如果真的去“逢事必从上下、左右、前后各个角度来认识辨知”,先不谈我们要付出多大的成本,等你成功的接近了真相,你能接受么?还拿原文的例子说,颜回可能确实是因为灰把米饭弄脏了而先吃了几口,这可能只是原因之一,但是更主要的原因难道不是因为他饿么?如果不饿为啥不能把脏的丢掉?至于孔子更是个心机婊好吧!你家颜徒弟这么忠心耿耿的人,在你眼里连先吃口米饭的权利都没有,还要拐着弯的试探人家,这种人我就不明白为啥我国人竟会把他当作圣人?估计是他心眼太多,而这正符合了我国人的传统文化。

扯远了,说回来。我比较认同的是最后一段的说明:看上去的两个人的沟通,实际上是六个人的沟通。你以为的你,他以为的你和真实的你在跟你以为的他、他以为的他和真实的他在沟通。

人和人的协作其实是一件非常困难的事情,因为信息不对等会产生很多猜忌、疑惑、瞧不起、看不上、嫉妒等等会影响协作的情绪,所谓的人心隔肚皮呀!所以资本主义很聪明的发明了契约这一约束条件。所有的沟通、合作、服务都可以通过一份文件的发布而达成协议,成为契约。大家所需要做的事情就是咬文嚼字,或者叫做字斟句酌。无论多少人的合作,大家只要在词语所表达的概念上达到一致,相互之间明白对方是用某个词语的时候其含义是什么,并把它确定下来,就可以形成一致。否则,就不能形成一致,英文中表达没有形成一致的词语更加直接,叫做not on the same page,咱俩压根没翻到同一页上。

有了契约,所有的协作都可以用服务化(SLA)的方式提供给别人,而理解别人和让别人理解自己变得在协作中不再那么重要。于是整个现代文明都建立在契约这个基石之上。互联网文化更是如此:所有通信都要有协议,所有服务都应以API(应用程序接口)的方式形成标准,所有合作都要在细节点上达成一致,形成契约,才能判断结果。这正是我国传统文化所缺少的,所以在我看来,梁山好汉、唐僧师徒都不是好的团队。他们一盘散沙似的基于信任领会精神而非契约形式的管理,能成功只是小概率事件。

日常工作中我们会见到无数人与人之使用的名词和其背后的含义并不一致的情况,就拿我从事的云计算行业来说,当有人说云计算的时候,一些人认为亚马逊的aws是云计算,另一些人则认为百度网盘是云计算。这还只是比较浅显的概念。当说到Linux时,有人认为说的是操作系统,而另一些人认为说的是个内核(kernel)。当有人说他想要个IAAS时,实际上他想要的是个PAAS,当他说想要的是个PAAS时,实际上他描述的功能则更像个SAAS。虚拟化这个词是什么意思?容器是不是虚拟化技术?docker是不是一种容器?虚拟化技术到底好处在哪里?这些在技术上有着明确定义的概念,却成了“工程师”眼中的哈姆雷特,一百个人眼里可能有两百个样子。你别觉得我是在开玩笑,一百个人眼里怎么可能有两百个样子?因为有一部分“客户”自己其实并不知道自己所说的名词倒是是什么意思,等工程师把他自己的理解的东西拿出来展示给“客户”之后,客户突然发现这不是他要的。其实根据上面两个人沟通实际上是六个人沟通的场景来看,一百个人眼里有两百个样子的“哈姆雷特”还真是算是幸运的情况了。

那么这个世界上就没有能不去咬文嚼字的合作么?当然有,当你和你身边的同事共事了多年,你们已经建立了基本一致的三观外加“技术观”,或者你们能充分明白对方观念里的某一个概念是什么意思的时候,那么你们的团队就应该不太需要咬文嚼字似的管理和协作了。到那时候,领会精神才是有效概念。所以我们才会看到,唐僧团队打磨了多年才能取得真经,梁山好汉即使打磨了多年,最后还是很多人不能理解松江为什么非要招安。

著名管理大师彼得德鲁克的名言是:管理是把事做对,而领导力则是做对的事(Management is doing things right; leadership is doing the right things.)。细究管理学的书籍的话,你会发现几乎任何一本都在告诉你,当别人交给你任务时你要问:为什么?什么时候?这还只是简单的提醒,更加标准的方式是至少要问清楚以下这7个问题:谁(who)?什么(what)?为什么(why)?何时(when)?在哪(where)?如何(how)?多少(how much)?如果交代事情的人没告诉我们这些,那么我们就要问清楚,以保证我们能把事情做对!如果交代事情的人描述事情的时候使用了一些名词,那么我更加建议大家去问清楚这些名词要表达的意思是不是你理解的意思?即使这些名词好像是大家都知道的。否则辞典为什么这么有用?

于是我们再回到同事跟我讨论问题(我试图理解他的需求)的场景,还有人会认为我咬文嚼字是多余的么?

Linux的IO调度

Linux的IO调度

Zorro] icon

Hi,我是Zorro。我的公众账号是:Linux系统技术。欢迎大家关注,我会不定期在这里更新文章,欢迎来一起探讨。
我的微博地址,有兴趣可以来关注我呦。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

Email: mini.jerry@gmail.com

QQ: 30007147

今天我们来谈谈:

Linux的IO调度

IO调度发生在Linux内核的IO调度层。这个层次是针对Linux的整体IO层次体系来说的。从read()或者write()系统调用的角度来说,Linux整体IO体系可以分为七层,它们分别是:

  1. VFS层:虚拟文件系统层。由于内核要跟多种文件系统打交道,而每一种文件系统所实现的数据结构和相关方法都可能不尽相同,所以,内核抽象了这一层,专门用来适配各种文件系统,并对外提供统一操作接口。
  2. 文件系统层:不同的文件系统实现自己的操作过程,提供自己特有的特征,具体不多说了,大家愿意的话自己去看代码即可。
  3. 页缓存层:负责真对page的缓存。
  4. 通用块层:由于绝大多数情况的io操作是跟块设备打交道,所以Linux在此提供了一个类似vfs层的块设备操作抽象层。下层对接各种不同属性的块设备,对上提供统一的Block IO请求标准。
  5. IO调度层:因为绝大多数的块设备都是类似磁盘这样的设备,所以有必要根据这类设备的特点以及应用的不同特点来设置一些不同的调度算法和队列。以便在不同的应用环境下有针对性的提高磁盘的读写效率,这里就是大名鼎鼎的Linux电梯所起作用的地方。针对机械硬盘的各种调度方法就是在这实现的。
  6. 块设备驱动层:驱动层对外提供相对比较高级的设备操作接口,往往是C语言的,而下层对接设备本身的操作方法和规范。
  7. 块设备层:这层就是具体的物理设备了,定义了各种真对设备操作方法和规范。

有一个已经整理好的Linux IO结构图,非常经典,一图胜千言:

Linux IO协议栈框架图

我们今天要研究的内容主要在IO调度这一层。它要解决的核心问题是,如何提高块设备IO的整体性能?这一层也主要是针对机械硬盘结构而设计的。众所周知,机械硬盘的存储介质是磁盘,磁头在盘片上移动进行磁道寻址,行为类似播放一张唱片。这种结构的特点是,顺序访问时吞吐量较高,但是如果一旦对盘片有随机访问,那么大量的时间都会浪费在磁头的移动上,这时候就会导致每次IO的响应时间变长,极大的降低IO的响应速度。磁头在盘片上寻道的操作,类似电梯调度,如果在寻道的过程中,能把顺序路过的相关磁道的数据请求都“顺便”处理掉,那么就可以在比较小影响响应速度的前提下,提高整体IO的吞吐量。这就是我们问什么要设计IO调度算法的原因。在最开始的时期,Linux把这个算法命名为Linux电梯算法。目前在内核中默认开启了三种算法,其实严格算应该是两种,因为第一种叫做noop,就是空操作调度算法,也就是没有任何调度操作,并不对io请求进行排序,仅仅做适当的io合并的一个fifo队列。

目前内核中默认的调度算法应该是cfq,叫做完全公平队列调度。这个调度算法人如其名,它试图给所有进程提供一个完全公平的IO操作环境。它为每个进程创建一个同步IO调度队列,并默认以时间片和请求数限定的方式分配IO资源,以此保证每个进程的IO资源占用是公平的,cfq还实现了针对进程级别的优先级调度,这个我们后面会详细解释。

查看和修改IO调度算法的方法是:

[zorro@zorrozou-pc0 ~]$ cat /sys/block/sda/queue/scheduler 
noop deadline [cfq] 
[zorro@zorrozou-pc0 ~]$ echo cfq > /sys/block/sda/queue/scheduler

cfq是通用服务器比较好的IO调度算法选择,对桌面用户也是比较好的选择。但是对于很多IO压力较大的场景就并不是很适应,尤其是IO压力集中在某些进程上的场景。因为这种场景我们需要更多的满足某个或者某几个进程的IO响应速度,而不是让所有的进程公平的使用IO,比如数据库应用。

deadline调度(最终期限调度)就是更适合上述场景的解决方案。deadline实现了四个队列,其中两个分别处理正常read和write,按扇区号排序,进行正常io的合并处理以提高吞吐量.因为IO请求可能会集中在某些磁盘位置,这样会导致新来的请求一直被合并,可能会有其他磁盘位置的io请求被饿死。因此实现了另外两个处理超时read和write的队列,按请求创建时间排序,如果有超时的请求出现,就放进这两个队列,调度算法保证超时(达到最终期限时间)的队列中的请求会优先被处理,防止请求被饿死。

不久前,内核还是默认标配四种算法,还有一种叫做as的算法(Anticipatory scheduler),预测调度算法。一个高大上的名字,搞得我一度认为Linux内核都会算命了。结果发现,无非是在基于deadline算法做io调度的之前等一小会时间,如果这段时间内有可以合并的io请求到来,就可以合并处理,提高deadline调度的在顺序读写情况下的数据吞吐量。其实这根本不是啥预测,我觉得不如叫撞大运调度算法,当然这种策略在某些特定场景差效果不错。但是在大多数场景下,这个调度不仅没有提高吞吐量,还降低了响应速度,所以内核干脆把它从默认配置里删除了。毕竟Linux的宗旨是实用,而我们也就不再这个调度算法上多费口舌了。

CFQ完全公平队列

CFQ是内核默认选择的IO调度队列,它在桌面应用场景以及大多数常见应用场景下都是很好的选择。如何实现一个所谓的完全公平队列(Completely Fair Queueing)?首先我们要理解所谓的公平是对谁的公平?从操作系统的角度来说,产生操作行为的主体都是进程,所以这里的公平是针对每个进程而言的,我们要试图让进程可以公平的占用IO资源。那么如何让进程公平的占用IO资源?我们需要先理解什么是IO资源。当我们衡量一个IO资源的时候,一般喜欢用的是两个单位,一个是数据读写的带宽,另一个是数据读写的IOPS。带宽就是以时间为单位的读写数据量,比如,100Mbyte/s。而IOPS是以时间为单位的读写次数。在不同的读写情境下,这两个单位的表现可能不一样,但是可以确定的是,两个单位的任何一个达到了性能上限,都会成为IO的瓶颈。从机械硬盘的结构考虑,如果读写是顺序读写,那么IO的表现是可以通过比较少的IOPS达到较大的带宽,因为可以合并很多IO,也可以通过预读等方式加速数据读取效率。当IO的表现是偏向于随机读写的时候,那么IOPS就会变得更大,IO的请求的合并可能性下降,当每次io请求数据越少的时候,带宽表现就会越低。从这里我们可以理解,针对进程的IO资源的主要表现形式有两个,进程在单位时间内提交的IO请求个数和进程占用IO的带宽。其实无论哪个,都是跟进程分配的IO处理时间长度紧密相关的。

有时业务可以在较少IOPS的情况下占用较大带宽,另外一些则可能在较大IOPS的情况下占用较少带宽,所以对进程占用IO的时间进行调度才是相对最公平的。即,我不管你是IOPS高还是带宽占用高,到了时间咱就换下一个进程处理,你爱咋样咋样。所以,cfq就是试图给所有进程分配等同的块设备使用的时间片,进程在时间片内,可以将产生的IO请求提交给块设备进行处理,时间片结束,进程的请求将排进它自己的队列,等待下次调度的时候进行处理。这就是cfq的基本原理。

当然,现实生活中不可能有真正的“公平”,常见的应用场景下,我们很肯能需要人为的对进程的IO占用进行人为指定优先级,这就像对进程的CPU占用设置优先级的概念一样。所以,除了针对时间片进行公平队列调度外,cfq还提供了优先级支持。每个进程都可以设置一个IO优先级,cfq会根据这个优先级的设置情况作为调度时的重要参考因素。优先级首先分成三大类:RT、BE、IDLE,它们分别是实时(Real Time)、最佳效果(Best Try)和闲置(Idle)三个类别,对每个类别的IO,cfq都使用不同的策略进行处理。另外,RT和BE类别中,分别又再划分了8个子优先级实现更细节的QOS需求,而IDLE只有一个子优先级。

另外,我们都知道内核默认对存储的读写都是经过缓存(buffer/cache)的,在这种情况下,cfq是无法区分当前处理的请求是来自哪一个进程的。只有在进程使用同步方式(sync read或者sync wirte)或者直接IO(Direct IO)方式进行读写的时候,cfq才能区分出IO请求来自哪个进程。所以,除了针对每个进程实现的IO队列以外,还实现了一个公共的队列用来处理异步请求。

当前内核已经实现了针对IO资源的cgroup资源隔离,所以在以上体系的基础上,cfq也实现了针对cgroup的调度支持。关于cgroup的blkio功能的描述,请看我之前的文章Cgroup – Linux的IO资源隔离。总的来说,cfq用了一系列的数据结构实现了以上所有复杂功能的支持,大家可以通过源代码看到其相关实现,文件在源代码目录下的block/cfq-iosched.c。

CFQ设计原理

在此,我们对整体数据结构做一个简要描述:首先,cfq通过一个叫做cfq_data的数据结构维护了整个调度器流程。在一个支持了cgroup功能的cfq中,全部进程被分成了若干个contral group进行管理。每个cgroup在cfq中都有一个cfq_group的结构进行描述,所有的cgroup都被作为一个调度对象放进一个红黑树中,并以vdisktime为key进行排序。vdisktime这个时间纪录的是当前cgroup所占用的io时间,每次对cgroup进行调度时,总是通过红黑树选择当前vdisktime时间最少的cgroup进行处理,以保证所有cgroups之间的IO资源占用“公平”。当然我们知道,cgroup是可以对blkio进行资源比例分配的,其作用原理就是,分配比例大的cgroup占用vdisktime时间增长较慢,分配比例小的vdisktime时间增长较快,快慢与分配比例成正比。这样就做到了不同的cgroup分配的IO比例不一样,并且在cfq的角度看来依然是“公平“的。

选择好了需要处理的cgroup(cfq_group)之后,调度器需要决策选择下一步的service_tree。service_tree这个数据结构对应的都是一系列的红黑树,主要目的是用来实现请求优先级分类的,就是RT、BE、IDLE的分类。每一个cfq_group都维护了7个service_trees,其定义如下:

struct cfq_rb_root service_trees[2][3];
struct cfq_rb_root service_tree_idle;

其中service_tree_idle就是用来给IDLE类型的请求进行排队用的红黑树。而上面二维数组,首先第一个维度针对RT和BE分别各实现了一个数组,每一个数组中都维护了三个红黑树,分别对应三种不同子类型的请求,分别是:SYNC、SYNC_NOIDLE以及ASYNC。我们可以认为SYNC相当于SYNC_IDLE并与SYNC_NOIDLE对应。idling是cfq在设计上为了尽量合并连续的IO请求以达到提高吞吐量的目的而加入的机制,我们可以理解为是一种“空转”等待机制。空转是指,当一个队列处理一个请求结束后,会在发生调度之前空等一小会时间,如果下一个请求到来,则可以减少磁头寻址,继续处理顺序的IO请求。为了实现这个功能,cfq在service_tree这层数据结构这实现了SYNC队列,如果请求是同步顺序请求,就入队这个service tree,如果请求是同步随机请求,则入队SYNC_NOIDLE队列,以判断下一个请求是否是顺序请求。所有的异步写操作请求将入队ASYNC的service tree,并且针对这个队列没有空转等待机制。此外,cfq还对SSD这样的硬盘有特殊调整,当cfq发现存储设备是一个ssd硬盘这样的队列深度更大的设备时,所有针对单独队列的空转都将不生效,所有的IO请求都将入队SYNC_NOIDLE这个service tree。

每一个service tree都对应了若干个cfq_queue队列,每个cfq_queue队列对应一个进程,这个我们后续再详细说明。

cfq_group还维护了一个在cgroup内部所有进程公用的异步IO请求队列,其结构如下:

struct cfq_queue *async_cfqq[2][IOPRIO_BE_NR];
struct cfq_queue *async_idle_cfqq;

异步请求也分成了RT、BE、IDLE这三类进行处理,每一类对应一个cfq_queue进行排队。BE和RT也实现了优先级的支持,每一个类型有IOPRIO_BE_NR这么多个优先级,这个值定义为8,数组下标为0-7。我们目前分析的内核代码版本为Linux 4.4,可以看出,从cfq的角度来说,已经可以实现异步IO的cgroup支持了,我们需要定义一下这里所谓异步IO的含义,它仅仅表示从内存的buffer/cache中的数据同步到硬盘的IO请求,而不是aio(man 7 aio)或者linux的native异步io以及libaio机制,实际上这些所谓的“异步”IO机制,在内核中都是同步实现的(本质上冯诺伊曼计算机没有真正的“异步”机制)。

我们在上面已经说明过,由于进程正常情况下都是将数据先写入buffer/cache,所以这种异步IO都是统一由cfq_group中的async请求队列处理的。那么为什么在上面的service_tree中还要实现和一个ASYNC的类型呢?这当然是为了支持区分进程的异步IO并使之可以“完全公平”做准备喽。实际上在最新的cgroup v2的blkio体系中,内核已经支持了针对buffer IO的cgroup限速支持,而以上这些可能容易混淆的一堆类型,都是在新的体系下需要用到的类型标记。新体系的复杂度更高了,功能也更加强大,但是大家先不要着急,正式的cgroup v2体系,在Linux 4.5发布的时候会正式跟大家见面。

我们继续选择service_tree的过程,三种优先级类型的service_tree的选择就是根据类型的优先级来做选择的,RT优先级最高,BE其次,IDLE最低。就是说,RT里有,就会一直处理RT,RT没了再处理BE。每个service_tree对应一个元素为cfq_queue排队的红黑树,而每个cfq_queue就是内核为进程(线程)创建的请求队列。每一个cfq_queue都会维护一个rb_key的变量,这个变量实际上就是这个队列的IO服务时间(service time)。这里还是通过红黑树找到service time时间最短的那个cfq_queue进行服务,以保证“完全公平”。

选择好了cfq_queue之后,就要开始处理这个队列里的IO请求了。这里的调度方式基本跟deadline类似。cfq_queue会对进入队列的每一个请求进行两次入队,一个放进fifo中,另一个放进按访问扇区顺序作为key的红黑树中。默认从红黑树中取请求进行处理,当请求的延时时间达到deadline时,就从红黑树中取等待时间最长的进行处理,以保证请求不被饿死。

这就是整个cfq的调度流程,当然其中还有很多细枝末节没有交代,比如合并处理以及顺序处理等等。


您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716


CFQ的参数调整

理解整个调度流程有助于我们决策如何调整cfq的相关参数。所有cfq的可调参数都可以在/sys/class/block/sda/queue/iosched/目录下找到,当然,在你的系统上,请将sda替换为相应的磁盘名称。我们来看一下都有什么:

[root@zorrozou-pc0 zorro]# echo cfq > /sys/block/sda/queue/scheduler
[root@zorrozou-pc0 zorro]# ls /sys/class/block/sda/queue/iosched/
back_seek_max  back_seek_penalty  fifo_expire_async  fifo_expire_sync  group_idle  low_latency  quantum  slice_async  slice_async_rq  slice_idle  slice_sync  target_latency

这些参数部分是跟机械硬盘磁头寻道方式有关的,如果其说明你看不懂,请先补充相关知识:

back_seek_max:磁头可以向后寻址的最大范围,默认值为16M。

back_seek_penalty:向后寻址的惩罚系数。这个值是跟向前寻址进行比较的。

以上两个是为了防止磁头寻道发生抖动而导致寻址过慢而设置的。基本思路是这样,一个io请求到来的时候,cfq会根据其寻址位置预估一下其磁头寻道成本。首先设置一个最大值back_seek_max,对于请求所访问的扇区号在磁头后方的请求,只要寻址范围没有超过这个值,cfq会像向前寻址的请求一样处理它。然后再设置一个评估成本的系数back_seek_penalty,相对于磁头向前寻址,向后寻址的距离为1/2(1/back_seek_penalty)时,cfq认为这两个请求寻址的代价是相同。这两个参数实际上是cfq判断请求合并处理的条件限制,凡事复合这个条件的请求,都会尽量在本次请求处理的时候一起合并处理。

fifo_expire_async:设置异步请求的超时时间。同步请求和异步请求是区分不同队列处理的,cfq在调度的时候一般情况都会优先处理同步请求,之后再处理异步请求,除非异步请求符合上述合并处理的条件限制范围内。当本进程的队列被调度时,cfq会优先检查是否有异步请求超时,就是超过fifo_expire_async参数的限制。如果有,则优先发送一个超时的请求,其余请求仍然按照优先级以及扇区编号大小来处理。

fifo_expire_sync:这个参数跟上面的类似,区别是用来设置同步请求的超时时间。

slice_idle:参数设置了一个等待时间。这让cfq在切换cfq_queue或service tree的时候等待一段时间,目的是提高机械硬盘的吞吐量。一般情况下,来自同一个cfq_queue或者service tree的IO请求的寻址局部性更好,所以这样可以减少磁盘的寻址次数。这个值在机械硬盘上默认为非零。当然在固态硬盘或者硬RAID设备上设置这个值为非零会降低存储的效率,因为固态硬盘没有磁头寻址这个概念,所以在这样的设备上应该设置为0,关闭此功能。

group_idle:这个参数也跟上一个参数类似,区别是当cfq要切换cfq_group的时候会等待一段时间。在cgroup的场景下,如果我们沿用slice_idle的方式,那么空转等待可能会在cgroup组内每个进程的cfq_queue切换时发生。这样会如果这个进程一直有请求要处理的话,那么直到这个cgroup的配额被耗尽,同组中的其它进程也可能无法被调度到。这样会导致同组中的其它进程饿死而产生IO性能瓶颈。在这种情况下,我们可以将slice_idle = 0而group_idle = 8。这样空转等待就是以cgroup为单位进行的,而不是以cfq_queue的进程为单位进行,以防止上述问题产生。

low_latency:这个是用来开启或关闭cfq的低延时(low latency)模式的开关。当这个开关打开时,cfq将会根据target_latency的参数设置来对每一个进程的分片时间(slice time)进行重新计算。这将有利于对吞吐量的公平(默认是对时间片分配的公平)。关闭这个参数(设置为0)将忽略target_latency的值。这将使系统中的进程完全按照时间片方式进行IO资源分配。这个开关默认是打开的。

我们已经知道cfq设计上有“空转”(idling)这个概念,目的是为了可以让连续的读写操作尽可能多的合并处理,减少磁头的寻址操作以便增大吞吐量。如果有进程总是很快的进行顺序读写,那么它将因为cfq的空转等待命中率很高而导致其它需要处理IO的进程响应速度下降,如果另一个需要调度的进程不会发出大量顺序IO行为的话,系统中不同进程IO吞吐量的表现就会很不均衡。就比如,系统内存的cache中有很多脏页要写回时,桌面又要打开一个浏览器进行操作,这时脏页写回的后台行为就很可能会大量命中空转时间,而导致浏览器的小量IO一直等待,让用户感觉浏览器运行响应速度变慢。这个low_latency主要是对这种情况进行优化的选项,当其打开时,系统会根据target_latency的配置对因为命中空转而大量占用IO吞吐量的进程进行限制,以达到不同进程IO占用的吞吐量的相对均衡。这个开关比较合适在类似桌面应用的场景下打开。

target_latency:当low_latency的值为开启状态时,cfq将根据这个值重新计算每个进程分配的IO时间片长度。

quantum:这个参数用来设置每次从cfq_queue中处理多少个IO请求。在一个队列处理事件周期中,超过这个数字的IO请求将不会被处理。这个参数只对同步的请求有效。

slice_sync:当一个cfq_queue队列被调度处理时,它可以被分配的处理总时间是通过这个值来作为一个计算参数指定的。公式为:time_slice = slice_sync + (slice_sync/5 * (4 – prio))。这个参数对同步请求有效。

slice_async:这个值跟上一个类似,区别是对异步请求有效。

slice_async_rq:这个参数用来限制在一个slice的时间范围内,一个队列最多可以处理的异步请求个数。请求被处理的最大个数还跟相关进程被设置的io优先级有关。

CFQ的IOPS模式

我们已经知道,默认情况下cfq是以时间片方式支持的带优先级的调度来保证IO资源占用的公平。高优先级的进程将得到更多的时间片长度,而低优先级的进程时间片相对较小。当我们的存储是一个高速并且支持NCQ(原生指令队列)的设备的时候,我们最好可以让其可以从多个cfq队列中处理多路的请求,以便提升NCQ的利用率。此时使用时间片的分配方式分配资源就显得不合时宜了,因为基于时间片的分配,同一时刻最多能处理的请求队列只有一个。这时,我们需要切换cfq的模式为IOPS模式。切换方式很简单,就是将slice_idle=0即可。内核会自动检测你的存储设备是否支持NCQ,如果支持的话cfq会自动切换为IOPS模式。

另外,在默认的基于优先级的时间片方式下,我们可以使用ionice命令来调整进程的IO优先级。进程默认分配的IO优先级是根据进程的nice值计算而来的,计算方法可以在man ionice中看到,这里不再废话。

DEADLINE最终期限调度

deadline调度算法相对cfq要简单很多。其设计目标是,在保证请求按照设备扇区的顺序进行访问的同时,兼顾其它请求不被饿死,要在一个最终期限前被调度到。我们知道磁头对磁盘的寻道是可以进行顺序访问和随机访问的,因为寻道延时时间的关系,顺序访问时IO的吞吐量更大,随机访问的吞吐量小。如果我们想为一个机械硬盘进行吞吐量优化的话,那么就可以让调度器按照尽量复合顺序访问的IO请求进行排序,之后请求以这样的顺序发送给硬盘,就可以使IO的吞吐量更大。但是这样做也有另一个问题,就是如果此时出现了一个请求,它要访问的磁道离目前磁头所在磁道很远,应用的请求又大量集中在目前磁道附近。导致大量请求一直会被合并和插队处理,而那个要访问比较远磁道的请求将因为一直不能被调度而饿死。deadline就是这样一种调度器,能在保证IO最大吞吐量的情况下,尽量使远端请求在一个期限内被调度而不被饿死的调度器。

DEADLINE设计原理

为了实现上述目标,deadline调度器实现了两类队列,一类负责对请求按照访问扇区进行排序。这个队列使用红黑树组织,叫做sort_list。另一类对请求的访问时间进行排序。使用链表组织,叫做fifo_list。

由于读写请求的明显处理差异,在每一类队列中,又按请求的读写类型分别分了两个队列,就是说deadline调度器实际上有4个队列:

  1. 按照扇区访问顺序排序的读队列。
  2. 按照扇区访问顺序排序的写队列。
  3. 按照请求时间排序的读队列。
  4. 按照请求时间排序的写队列。

deadline之所以要对读写队列进行分离,是因为要实现读操作比写操作更高的优先级。从应用的角度来看,读操作一般都是同步行为,就是说,读的时候程序一般都要等到数据返回后才能做下一步的处理。而写操作的同步需求并不明显,一般程序都可以将数据写到缓存,之后由内核负责同步到存储上即可。所以,对读操作进行优化可以明显的得到收益。当然,deadline在这样的情况下必然要对写操作会饿死的情况进行考虑,保证其不会被饿死。

deadline的入队很简单:当一个新的IO请求产生并进行了必要的合并操作之后,它在deadline调度器中会分别按照扇区顺序和请求产生时间分别入队sort_list和fifo_list。并再进一步根据请求的读写类型入队到相应的读或者写队列。

deadline的出队处理相对麻烦一点:

  1. 首先判断读队列是否为空,如果读队列不为空并且写队列没发生饥饿(starved < writes_starved)则处理读队列,否则处理写队列(第4部)。
  2. 进入读队列处理后,首先检查fifo_list中是否有超过最终期限(read_expire)的读请求,如果有则处理该请求以防止被饿死。
  3. 如果上一步为假,则处理顺序的读请求以增大吞吐。
  4. 如果第1部检查读队列为空或者写队列处于饥饿状态,那么应该处理写队列。其过程和读队列处理类似。
  5. 进入写队列处理后,首先检查fifo_list中是否有超过最终期限(write_expire)的写请求,如果有则处理该请求以防止被饿死。
  6. 如果上一步为假,则处理顺序的写请求以增大吞吐。

整个处理逻辑就是这样,简单总结其原则就是,读的优先级高于写,达到deadline时间的请求处理高于顺序处理。正常情况下保证顺序读写,保证吞吐量,有饥饿的情况下处理饥饿。

DEADLINE的参数调整

deadline的可调参数相对较少,包括:

[root@zorrozou-pc0 zorro]# echo deadline > /sys/block/sdb/queue/scheduler
[root@zorrozou-pc0 zorro]# ls /sys/block/sdb/queue/iosched/
fifo_batch  front_merges  read_expire  write_expire  writes_starved

read_expire:读请求的超时时间设置,单位为ms。当一个读请求入队deadline的时候,其过期时间将被设置为当前时间+read_expire,并放倒fifo_list中进行排序。

write_expire:写请求的超时时间设置,单位为ms。功能根读请求类似。

fifo_batch:在顺序(sort_list)请求进行处理的时候,deadline将以batch为单位进行处理。每一个batch处理的请求个数为这个参数所限制的个数。在一个batch处理的过程中,不会产生是否超时的检查,也就不会产生额外的磁盘寻道时间。这个参数可以用来平衡顺序处理和饥饿时间的矛盾,当饥饿时间需要尽可能的符合预期的时候,我们可以调小这个值,以便尽可能多的检查是否有饥饿产生并及时处理。增大这个值当然也会增大吞吐量,但是会导致处理饥饿请求的延时变长。

writes_starved:这个值是在上述deadline出队处理第一步时做检查用的。用来判断当读队列不为空时,写队列的饥饿程度是否足够高,以时deadline放弃读请求的处理而处理写请求。当检查存在有写请求的时候,deadline并不会立即对写请求进行处理,而是给相关数据结构中的starved进行累计,如果这是第一次检查到有写请求进行处理,那么这个计数就为1。如果此时writes_starved值为2,则我们认为此时饥饿程度还不足够高,所以继续处理读请求。只有当starved >= writes_starved的时候,deadline才回去处理写请求。可以认为这个值是用来平衡deadline对读写请求处理优先级状态的,这个值越大,则写请求越被滞后处理,越小,写请求就越可以获得趋近于读请求的优先级。

front_merges:当一个新请求进入队列的时候,如果其请求的扇区距离当前扇区很近,那么它就是可以被合并处理的。而这个合并可能有两种情况,一个是向当前位置后合并,另一种是向前合并。在某些场景下,向前合并是不必要的,那么我们就可以通过这个参数关闭向前合并。默认deadline支持向前合并,设置为0关闭。

NOOP调度器

noop调度器是最简单的调度器。它本质上就是一个链表实现的fifo队列,并对请求进行简单的合并处理。调度器本身并没有提供任何可疑配置的参数。

各种调度器的应用场景选择

根据以上几种io调度算法的分析,我们应该能对各种调度算法的使用场景有一些大致的思路了。从原理上看,cfq是一种比较通用的调度算法,它是一种以进程为出发点考虑的调度算法,保证大家尽量公平。deadline是一种以提高机械硬盘吞吐量为思考出发点的调度算法,尽量保证在有io请求达到最终期限的时候进行调度,非常适合业务比较单一并且IO压力比较重的业务,比如数据库。而noop呢?其实如果我们把我们的思考对象拓展到固态硬盘,那么你就会发现,无论cfq还是deadline,都是针对机械硬盘的结构进行的队列算法调整,而这种调整对于固态硬盘来说,完全没有意义。对于固态硬盘来说,IO调度算法越复杂,额外要处理的逻辑就越多,效率就越低。所以,固态硬盘这种场景下使用noop是最好的,deadline次之,而cfq由于复杂度的原因,无疑效率最低。


您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716


 

关于情商

中午根同事一起吃饭,同事因为看了我的博客上的《你爱我,与我何干?》而感慨说:唉,你的情商也太低了。怎么能跟长辈这么说该你屁事这样的话?碰巧,当我跟伯父说这话的时候,父亲就提醒过我,也说我这样说话情商低,在外容易得罪人。其实几乎每次我放假回家跟父母在一起的时候,他们都会不断的提醒我,说我们家人都是直脾气,不懂得圆滑处事,他们年轻的是候就吃过很多亏,所以希望我能吸取教训,不要重蹈覆辙,做人要圆滑。那么我理解的这个“圆滑”,其实就是所谓的高情商,也就是说,我在他们眼里也是低情商。曾经那个不太懂事的我也一直以来我也都以为自己知道自己是低情商、不圆滑的人,并且曾以此为个性。直到有一天,我认真思考了一下这个问题:我的情商为什么低?于是随之而来的另一个问题就是:到底什么是情商?于是我就找了很多资料,其中知乎上的几个帖子让我茅塞顿开:
https://www.zhihu.com/question/21112415
https://www.zhihu.com/question/20543245

不知道有多少人思考过这个问题:什么是情商?

情商的概念属于心理学范畴我们这里不去细节讨论定义,如果只从大家方便理解的方面考虑,情商主要是指以下两个能力:

1、控制自己情绪的能力。
2、控制别人情绪的能力。

人类是一个社会动物,我们需要协作,那么必然需要交流,而所谓的情商也就表现在这个过程中。以我为例,我表达自己的观点时,说的是:关你屁事!我为什么要这样说?因为这个语境和用词可以最直观的表达出我对此事的情绪和看法,这是成本最低的表达方式。当然,当时的上下文语境也并不是跟谁吵架,而仅仅是单纯的表达态度。我本身说这句话并不带情绪,选词也是深思熟虑过的。并且我也做好了长辈会发作的心里预期。而长辈此时确实因为我的用词和语气而发作,认为我是不礼貌的。当然之后我们没有不欢而散,而是我陪上笑脸赶紧道歉,最后长辈也明白了我要表达的意思。在这个过程中,究竟谁情商更高呢?

如果仅从自我的情绪控制上看,我毫不谦虚的说,本人情商很高,我可以在整个过程中控制好自己的情绪。该表达观点的时候表达,该哄长辈开心的时候就哄。而相反是长辈情绪控制相对较差,因为自己辈份大而不能接受平等的沟通,于是就以愤怒来对待,这是情商差的表现。当然,我这位大伯毕竟生活阅历丰富,还是哄的好的,在我的道歉和劝说下,他还是能够控制住自己情绪的,还是有一定情商的。在相同的场景下,很多人是连哄也哄不好的,情绪自控能力当然更差。

通过这个例子来看,我发现其实并不是我的情商有多低,而往往是别人控制自己情绪的能力太差。这些人因为一些原因不能跟你平等沟通,当你直接表达自己意见的时候,他们就可能受不了。这仍然与我国的传统文化密不可分,我们强调三纲五常,君君臣臣、父父子子。我们的文化中没有平等,所有人都应在在自己的位置上,面对别人的时候要么是跪着唯唯诺诺,要么是站着颐指气使。我们的传统文化中根本没有情商的概念,所以对于中华文化来说,所谓的高情商、圆滑、老油条其实就是装孙子。就是我不管你怎么样,我先跪下行不?你总不能说我情商低了吧?

请别理解成我是给自己的不礼貌找借口,起码的礼貌我们当然要讲,起码的尊老爱幼我们当然要坚坚持。尊敬领导,尊重同事这些基本的道德必须有。我很清楚我根长辈说关你屁事是不礼貌的,我道歉并也认为自己是错的。长辈当然有权利生气,但是不要混淆概念,当我们讨论情商的时候,就是指我们再某些情况下能否控制自己的情绪。

这只是情商的一方面,是从管理自己情绪来说的。情商还有管理别人情绪能力的要求。说白了就是控制别人情绪的能力。一般情况我们都是希望让别人高兴的,因为绝大多数场景都是我们有求于别人,让人开心了才能获得利益。但是不排除让人生气也可能获得利益的情况,比如:三气周瑜。你会觉得诸葛亮在气周瑜的时候是情商低么?在这个例子中,诸葛亮恰恰是情商管理大师。所以说,控制别人的情绪最重要的是同理心,就是很清楚别人什么情况下会生气,什么情况下会开心,并以此决定自己的行为,来达到目的。从这一点说,当你在说某一句话之前,就知道对方会是什么反应的时候,你的情商实际上就是高人一筹了。但是也会有很多人因为对自己的情绪掌握不好,而在明知道对方会不开心的情况下,还非要这样做,导致一个损人不利己的结果,这必然是低情商的表现。所以,我觉得提高情商的核心方法仍然是修炼对自己情绪的控制。

在管理学的著作中,情商也更加强调控制自己情绪,因为别人的情绪虽然可以被引导,但是想要在管理上真正可控是不可能的,毕竟那是别人的情绪。我认为,管理学强调对自己的情绪的控制是很好的思路,因为在一个合作场景中,如果每个人都可以提升自己情绪的控制能力,那么其他人就可以减少在控制别人情绪方面所付出的成本,而使沟通变得更直接和顺畅。这也就是现代组织形式和传统体制化的组织形式的人际关系的差别:当组织不存在竞争压力的时候,其体制就会腐化为强调伦常道德,官本位等体制化方式运作,因为资源都集中在上层,你只有让上层的人舒服了,你才能分到资源,你要么跪着,要么站着。有人形容这样的体制就像是一群猴子爬树,上面的猴子看下面,一群笑脸。下面的猴子看上面,全是屁股。其实全都只是屁股还好,有时你还要接着屁和屎。当组织是在自由市场竞争,需要通过不断提升服务能力和降低成本而盈利而生存的时候,当然内部沟通成本越少越好,这时大家需要把精力都用在做事上,所以需要高情商的人合作,大家都能控制自己的情绪,减少不必要的沟通成本。

这也许也就是乔布斯说的那句话的原因:一流人才的自尊心,不需要你呵护。

你爱我,与我何干?

春节期间休息在家陪父母,今天又是姑姑生日,于是按照传统要去她家聚会,这几乎是每年的惯例。饭桌上酒过三巡菜过五味后,大伯表达了一下他对我们这一代人的看法,大概的意思就是说我们这一代人对人际关系的处理比较差劲,比如跟亲戚之间有隔阂,堂兄弟之间也不爱多联系;比如对待爱情也不成熟,处理不好夫妻男女朋友之间的关系。总之,在他眼中,我们这一代人就是比较矫情。由于之前他也一直好奇我为什么总不爱说话,所以我决定在这一话题上代表我们这“一代人”表示一下我的看法。我不知道我的答案是不是能代表我们这一代人,我说的是:“关你屁事?”

结果呢?自然是大伯大怒,当场怒斥我不礼貌。我呢?当然是立即承认错误,之后继续不说话,然后听大伯和我父亲以及其它亲戚结成同盟批评我:“不养儿不知父母恩”等等等等大家可以自行补脑的话。

春节对于我来说,从小到大就是这样的纠结的一个节。在这样的喜庆日子里,亲友团聚的时刻却总是让我感觉到无地自处,全身都不适应。每年,都会经历这样一轮节日的洗礼,这种洗礼的结果就是,你总是在不断的反省是不是自己什么地方做错了?需要改正什么地方?因为按照他们的说法,由于我们的各种不“正常”,我们早该在这个社会上被淘汰掉了,我们早该没有任何生存能力养活自己而回家继续啃老了。不过我是如此幸运,社会是如此宽容,我还能养的活自己。但是事实是这样么,该反省的真的是我们么?

看看日历恰逢今天又是情人节,所以觉得很适合讨论一个话题,就是爱情。其实爱情是我们国家传统文化中的最核心的内容,儒家倡导的最核心的思想就是仁。仁的意思就是“仁者爱人”,就是说白了就是要爱别人的意思。仁字也设计的很形象,二人世界。于是我们的传统文化中所倡导的很多关系都是这种二人世界的演化版,比如:孝、忠、悌。仔细分析一下,中国人所有的关系似乎都只是二人世界,而自己的独立生活空间往往是没有的。就比如什么是孝?顺者为孝。就是一切按照老人的想法做就是顺,于是就做到了孝。就比如我,由于怕说实话伤害了长辈的感情而不孝,所以就不说话。于是人家觉得你是不是有心事?有什么难处?让人操心了,我就又是不孝。我说了实话,于是就是不礼貌,不注意讲话,又是不孝。当然我也可以选择说假话,赞成大伯的说法,说我们这代人就是矫情,就是处理不好各种关系,那我是不是也太让他们不省心了?先不说我自己是不是能接受这种自我摧残,即便说了承认了自己不省心这样的话骗他们是不是也是不孝?

分析这样的问题,我有一个笨办法,就是看看我能选择的所有情况是不是都是错误答案,如果是,那就说明是题出错了。这明显就是题出错了。那么究竟错在哪里?我爱你,错了么?

对于爱,曾经最喜欢张爱玲的一句话:我爱你,与你无关。曾经我认为这是最健康的爱的方式。但是我从没有仔细想过这句话也可以用在亲情的爱上。中国人对于爱,有着太多的憧憬。这种憧憬实际上更多的是对回报的憧憬。中国人对爱的隐含意思是:既然我这么爱你,你就应该以我觉得合适的方式和量级来回报给我。如果不是这样,那就是你不爱我。

我想这就是这个社会中绝大多数人对爱的逻辑。于是才有了逼婚,才有了结婚后逼生孩子。有多少人连自己的结婚日期都不是自己定的,而是按照父母的时间表由父母进行安排,搞得自己都搞不清楚是不是自己结婚?甚至有的父母连你考什么大学,报什么专业都帮你确定好了的?更有甚者,父母从小就开始培养孩子的某一个技能,以便让孩子长大以后就从事相关工作。在他们眼里,孩子已经不是孩子了,只是他们实现自己梦想的工具而已。他们以爱的名义不同程度的侵入了孩子的私人空间,并美其名曰,我爱你!搞得你无论怎么选都是错的,因为你不可以有自己的选择。这不是你的人生,这是他们的人生。

说到这里,有些不近人情了。父母可能真的并不觉得这是子女的私事,他们也真的觉得这是爱你。并且会觉得我们不养儿不知父母恩,等你养了孩子你就知道了。我确实还没养孩子,我也确实可能不理解他们的心情,但是关于这一点,我想我更赞同胡适的看法,在胡适答汪长禄的信中他这样写道:

““父母于子无恩”的话,从王充、孔融以来,也很久了。从前有人说我曾提倡这话,我实在不能承认。直到今年我自己生了一个儿子,我才想到这个问题上去。我想这个孩子自己并不曾自由主张要生在我家,我们做父母的不曾得他的同意,就糊里糊涂的给了他一条生命。况且我们也并不曾有意送给他这条生命。我们既无意,如何能居功?如何能自以为有恩于他?他既无意求生,我们生了他,我们对他只有抱歉,更不能“市恩”了。我们糊里糊涂的替社会上添了一个人,这个人将来一生的苦乐祸福,这个人将来在社会上的功罪,我们应该负一部分的责任。说得偏激一点,我们生一个儿子,就好比替他种下了祸根,又替社会种下了祸根。他也许养成坏习惯,做一个短命浪子;他也许更堕落下去,做一个军阀派的走狗。所以我们“教他养他”,只是我们自己减轻罪过的法子,只是我们种下祸根之后自己补过弥缝的法子。这可以说是恩典吗?

所说的,是从做父母的一方面设想的,是从我下人对于我自己的儿子设想的,所以我的题目是“我的儿子”。我的意思是要我这个儿子晓得我对他只有抱歉,决不居功,决不市恩。至于我的儿子将来怎样待我,那是他自己的事。我决不期望他报答我的恩,因为我已宣言无恩于他。”

等我有了孩子之后,我将要以这样的心情教他养他。也做一个像张爱玲、胡适那样有着自由主义爱情观的人。

也许我们这一代的人的责任就是,承接好上一代对我们“无微不至”的爱,并把自由主义爱情观通过行动传递给下一代,让他们有独立的人格和生活空间。

那么今天,请允许我说:你爱我,与我何干?

Cgroup – Linux的网络资源隔离

Cgroup – Linux的网络资源隔离

Zorro] icon

Hi,我是Zorro。这是我的微博地址,如果你有兴趣,可以来关注我呦。

这是我的博客地址,我会不定期在这里更新文章,如有谬误,欢迎随时指正。

另外,我的其他联系方式:

Email: <mini.jerry@gmail.com>

QQ: 30007147

由于本文不会涉及一些网络基础知识的讲解以及iproute2相关命令的使用的讲解,建议如果想要更好理解本文,之前应该对网络知识、tc命令和LARTC的文档有一定了解。如果本文中有什么知识点让不够清楚,可以结合LARTC文档一起服用。

想要直接上手配置cgroup的网络资源隔离的人,可以直接看本文倒数第二部分:使用cgroup限制网络流量

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

今天我们来谈谈:

##Linux的网络资源隔离

如果说Linux内核的cgroup算是个新技术的话,那么它的网络资源隔离部分的实现算是个不折不扣的老技术了。实际上是先有的网络资源的隔离技术,才有的cgroup。或者说是先有的网络资源的隔离才有的2.4、2.6版本的Linux内核,而现在的最主流的内核版本应该是3.10了(考虑到android手机的出货量,你公司那几千几万台服务器真的算是个零头对吧?)。好吧,Linux早在内核2.2版本就已经引入了网络QoS的机制,并且网络资源的隔离功能只是其所实现功能的一部分而已。无论如何,cgroup并没有再重新搞一套网络资源隔离的实现,而是直接使用了Linux的iproute2的traffic control(tc)功能。实际上网络资源隔离的文档真的不用我再多写什么了,我最亲爱的前同事+朋友+导师——johnbull同志早已经在2003年的非典期间就因为无聊而完成了非常高质量的相关技术文档翻译工作,将这方面最权威的LARTC(Linux Advanced Routing & Traffic Control)文档翻译成了中文版。

英文版链接

中文版链接

曾经chinaunix的资深版主johnbull同志现在在新浪微博工作,所以经常在微博出没,如果对以上文档有兴趣和疑问的人可以直接去找他对质,传送门在此

其实原则上说,本技术文章已经讲完了,但是为了不让大家有种上当受骗的感觉,我觉得我还是有必要从cgroup的角度再来讲讲tc,也算是对TC近几年发展做一个补充。

###什么是队列规则

tc命令引入了一系列概念,其中我们最需要先理解的就是队列规则。它的英文名字叫做queueing discipline,在tc命令中也叫qdisc,或者直接简写为qd。我们先来看看它,有个感性的认识:

在我的虚拟机的centos7中,它是这样的:

[root@localhost Desktop]# tc qd ls
qdisc pfifo_fast 0: dev eno16777736 root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1

在我的台式机上装的archlinux(更新到了当前最新版的4.3.3内核)以及fedora 23上是这样的:

[root@zorrozou-pc0 zorro]# tc qd ls
qdisc noqueue 0: dev lo root refcnt 2
qdisc fq_codel 0: dev enp2s0 root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5.0ms interval 100.0ms ecn

在公司的服务器上是这样的:

[root@tencent64 /data/home/zorrozou]# tc qd ls
qdisc mq 0: dev eth1 root
qdisc pfifo_fast 0: dev tun0 root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1
qdisc pfifo_fast 0: dev veth213_121_54 root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1
qdisc pfifo_fast 0: dev veth213_135_194 root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1
qdisc pfifo_fast 0: dev veth213_123_25 root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1
qdisc pfifo_fast 0: dev veth213_121_112 root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1
qdisc pfifo_fast 0: dev veth213_123_207 root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1
qdisc pfifo_fast 0: dev veth213_123_82 root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1
qdisc pfifo_fast 0: dev veth213_117_111 root refcnt 2 bands 3 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1

从以上输出大家应该可以判断出来,这个所谓的qdisc是针对网卡的,每有一个网卡就会有一个qdisc。而且如果你用过ip命令并且比较细心的话,应该早就注意到ip ad sh的时候也会出现相关的信息:

[zorro@zorrozou-pc0 ~]$ ip ad sh
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: enp2s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
link/ether 34:64:a9:15:a2:17 brd ff:ff:ff:ff:ff:ff
inet 10.18.73.69/24 brd 10.18.73.255 scope global dynamic enp2s0
valid_lft 28283sec preferred_lft 28283sec
inet6 fe80::3664:a9ff:fe15:a217/64 scope link
valid_lft forever preferred_lft forever

虽然看上去有些高深莫测,但是qdisc其实是个挺简单的概念,它就是它字面的意思:队列规则,或者叫做排队规则。我们都知道,网络数据都是被封装成一个一个的数据包进行传输的。如果网卡相当于数据包要出发的大门的话,那么qdisc无非就是规定了这些包在出发前如果需要排队的话该怎么排。我们先拿这个叫做pfifo_fast的队列规则来举例子描述一下吧,这个qdisc实现了一个以数据包(package)为单位的fifo队列,实际上可以认为是实现了三个队列(叫做bands),给每个队列定了一个优先级,以实现带优先级的排队规则。我们举个现实中的例子再来说明一下,大家都应该有去公交车站排队的经验吧?(神马?作为中国人你从来不排队?)无论怎样,我们假定你是排队的。每来一次公交车,就相当于网卡处理一次队列中的数据包,而每个人就是一个数据包。那么我们一般人到了公交站,如果发现前面已经排了一队人,此时根据fifo(first in first out)的规则,我们会排在队列尾部。如果来车了,就从队列头的人先上车,车满就走,没上完的人继续等待。但是我们也知道,如果此时来了个孕妇或者大爷大娘等一些按照我们社会美德要求应该让他们优先的乘客的话,这些人应该有权利优先上车。那么怎么办呢?我们公交站台的解决办法一般是直接让他们去队列头插队就好,但是如果空间允许的话,我们可以考虑多建立一个队列。让这些可以优先上车的人排一个队,正常人排一个队,车来了先上优先级比较高的那个队列中的人,他们都上完了再让一般队列中上人车。这样就实现了一个简单的队列规则,大家根据自己的情况去选择排队就好了。

pfifo_fast实现了一个类似上述描述的队列规则,区别是它实现了3个优先级的队列(bands),每个数据包来了都根据自己的情况选择一个band进行排队,每个band都是fifo方式处理数据包。它总是先处理优先级最高的band,直到没有数据包了再处理下一个优先级的band,直到三个都处理完,或者本次处理不完,继续等着下次处理。那么数据包按什么规则进行选择自己该进入哪个band呢?这就是后面显示的priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1的含义,这个字段描述了一个priomap,可以理解为优先级位图,后面的16个不同的位,表示相关制如果为真时的数据包应该进入哪个队列,一共有0、1、2三个队列。而这个16位的位图标记,针对的就是我们IP报头中的TOS字段。根据IP协议的定义我们知道,TOS字段8位中的4位分别是用来标示最小延时、最大吞吐量、最大可靠性和最小消费四种数据包类型的,IP协议原则上会根据这些标示的不同以不同的QOS对上层交付不同的服务质量。这些不同的搭配理论上一躬有16种,这就是priomap所映射的优先级的概念。

如果你对TOS的概念还不熟悉,请自行补充网络相关基础知识。推荐的教材是《TCP/IP详解卷1》。

pfifo_fast队列处理过程如图所示:

pfifo_fast

pfifo_fast一般情况下是内核对网卡默认选择的qdisc,它虽然提供了简单的优先级分类的支持,但是并没有提供可供修改的参数,就是说默认的优先级分类设置不能更改,也没有提供相关限速的功能。这个队列规则在一般情况下工作的都很稳定,但是最近Linux已经开始放弃使用这个qd作为默认的队列规则而改用一种叫做fq_codel的qdisc了。主要原因是,由于移动互联网的广泛应用,一种叫做Bufferbloat的现象影响越来越大了。

###Bufferbloat

Bufferbloat现象最初是用来形容在一个分组交换网络上,路由器为防止丢包,往往buffer缓冲区都会实现的很大,但是这种过大的fifo缓冲区可能导致数据包buffer中等待时间过长而导致很多问题(后面会有分析)。再加上网络上TCP的拥塞控制算法的影响,以及很多商业操作系统甚至并不实现拥塞控制,导致数据传输质量抖动很大(全局同步),甚至于达到服务不可用的状态。

后来我们发现,Bufferbloat这种现象比较广泛的存在在各种系统中,只要系统中使用了类似队列、缓存等机制的时候,就在某些极端状态下出现这种类似雪崩的现象。我们简要描述一下这个状态。我们先简单构建一个试用buffer的场景,如图所示:

fifo

根据图的描述,我们假定这个简单的fifo就是我们要的buffer系统,它在两个处理过程之间充当缓冲区的作用。每个请求从队列的上面进入排队,然后依次被下面的处理程序处理。大家应该知道buffer的作用:一个缓冲器的作用主要是弥补两个处理系统之间的速度差异,能够在一定程度的请求速度抖动的时候缓解处理速度慢而导致的请求失败。假设,后段处理请求的速度为1000个/s,每个请求平均长度为100byte,队列长队为1Mbyte,此时,如果请求突然增加到了2000个/s,那么这个压力直接压给后端是处理不过来的,每秒钟就要丢弃1000个包。所以我们使用一个buffer,可以让这一秒钟来的请求先处理1000个,然后有1000个排在队列中,下一秒处理。只要来的请求的抖动范围还算正常,我们的系统将会工作的良好,没有失败的请求。

对于一般的系统,我们发送的请求都是有延时要求的,鉴于我们的系统每秒钟可以处理1000个请求,所以每个请求的处理时间平均为1ms。而我们的系统基于目前的处理时间,对外提供了100ms的延时SLA,就是说,后端系统保证每个请求的处理时间是100ms以内,这已经很大了,是正常情况的100倍。于是前端的请求方,会根据后端给出的SLA在程序中设定一个超时时间,在这个例子中就应该是100ms,这可能意味着,程度调用后端系统,如果等待100ms还没有结果,那么将重试一次或者几次不等,之后应该会返回失败。场景就是这样一个场景,那么我们来看看究竟什么是bufferbloat?

假定现在因为业务问题,比如上线了一个秒杀的抢购活动,导致从前端发来的请求一瞬间远远大于后端的处理能力。比如,一秒钟内产生了10000次请求,这一万次请求都会立即进入队列中等待后端处理。因为后端的处理速度是1000次每秒,所以可以想像,当前在队列中的最后一个数据包至少要等待9秒钟才能处理到。实际上根本处理不到这最后一个请求,由于我们设置了100ms的超时时间,那么调用方将很快因为发现100ms中没有返回而重试一次,于是又来了将近10000个请求。这些请求都积压在了队列中,还没交给后端进行处理,如果交给了后端处理,后端肯定会因为压力变大处理变慢,而导致处理事件超过100ms的SLA,会在超时之后告诉前端本次请求失败(如果是这样实现的话),而现在由于队列的存在,并大量的积压请求,导致调用方不能明确的得知失败。所以一般都是等待至少一次超时重试一次再失败,当然也有很多情况会重试个4,5次也说不定。

无论如何,这突发的10000个请求的流量来了之后,如果平均每个请求100字节,这1M的缓冲区就已经满了,后续再有任何请求来,都会排在队列末尾,一直等到前面的请求处理完再处理这个请求,而此时因为整体处理时间很慢,要将此队列中的全部请求处理完需要9秒钟,无论如何,这个请求都已经超时失败了。这个时候后端服务一直满载的处理队列中的请求,而前端还不断有新请求源源不断的放进队列,但是由于超时,前端所有请求都是返回失败,后端所处理的请求也都是等待时间超过100ms的无效的请求,即使成功返回结果给前端,前端也不会要了。效果就是后端很忙,而整体服务却是不可用的。此时哪怕请求平均速度恢复到1000个每秒,服务也无法恢复。这就是一个典型的bufferbloat场景。

于是我们可以考虑一下这个场景会发生在什么地方?比如buffer比较大的路由器,由于tcp的流量控制和重试机制导致网络质量的抖动;比如一个后端的数据库系统为了能够承载更大的吞吐量而添加了队列系统;比如io调度;比如网卡调度;只要是大buffer的场景都会可能产生类似的问题。那么该如何解决这个问题呢?于是主动队列管理算法应运而出了。

###AQM

AQM就是主动队列管理(Active Queue Management)的英文缩写,其思路就是对buffer缓冲的队列管理采取有效的主动管理手段,并不等待队列满之后才被动丢弃请求,而是在某个条件触发的情况下主动对请求进行丢弃,以防止类似Bufferbloat现象的发生。最简单的AQM思路就是监控队列长度,当队列长度一直维持在最大长度的时候,开始对新入队的数据包进行丢弃,直到使拥塞恢复(根据上面的例子可以想像,不断减少队列长度,就可以让新来的请求等待时间变短,直到可以正常服务)。这种做法虽然可以最终使拥塞恢复,但是整个过程并不十分理想,bufferbloat现象仍然存在。由于是对新入队数据包进行丢弃,所以容易在类似TCP拥塞控制的使用场景下引发全局同步现象,在很多场景下还会有死锁。所以我们需要更先进的队列管理算法。

###RED算法

RED算法主要是为了解决全局同步现象而产生的算法,其基本思路是,通过监控平均队列长度来探测是否有拥塞,一旦发现开始拥塞,就以某一个概率从队列中(而不是队列尾)开始丢弃请求(在网络上也可以通过ecn通知连接有拥塞)。

对于RED来说,关键的可配置参数有这样几个:

min:最小队列长度。

max:最大队列长度。

probability:可能性,取值范围为0.00 – 1,一般可以理解为百分比,比如0.01为1%。

有了以上几个关键参数,RED算法就可以工作了,其工作的原理大概是这样的。首先,RED会对目前队列状态计算一个平均队列长度(算法采用的是指数加权平均算法计算的,在此不做更细节的说明),然后检查当前队列的平均长度是否:

  1. 低于min:此时不做任何处理,队列压力较小,可以直接正常处理。
  2. 在min和max之间:此时界定为队列感受到阻塞压力,开始按照某一几率P从队列中丢包,几率计算公式为:P = probability * (平均队列长度 - min)/(max - min)。
  3. 高于max:此时新入队的请求也将丢弃。

所以probability可以理解为当队列感受到阻塞压力的时候,最大的丢包几率是多少。知道了这几个参数,我们就可以了解一下如何在Linux上进行RED的配置了,其实很简单,使用以下命令即可:

[root@zorrozou-pc0 zorro]# tc qd ls
qdisc noqueue 0: dev lo root refcnt 2
qdisc fq_codel 0: dev enp2s0 root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5.0ms interval 100.0ms ecn
[root@zorrozou-pc0 zorro]# tc qd add dev enp2s0 root red limit 200000 min 20000 max 40000 avpkt 1000 burst 30 ecn adaptive bandwidth 5Mbit
[root@zorrozou-pc0 zorro]# tc qd ls
qdisc noqueue 0: dev lo root refcnt 2
qdisc red 8001: dev enp2s0 root refcnt 2 limit 200000b min 20000b max 40000b ecn adaptive

这样我们就将默认的qdisc规则改为了RED,解释一下相关参数:

首先是命令前部分:

tc qd add dev enp2s0 root

这部分没什么可解释的,唯一需要说明的是root参数,这个参数表示根节点,修改了这个参数描述的队列一般表示我们整个这个网卡所发出的数据包都用的是指定的规则,暂时我们还用不到其他节点,所以就只是root就可以了。另外请注意,目前所学习的队列规则只对发出的数据包有效。

之后是red参数,在这里描述使用什么队列规则。在之后丢失red这个队列规则所要使用的参数描述,具体可以通过man tc-red找到帮助。我们简单解释一下:

limit:此队列的字节数硬限制。配置的长度应该比max大。但是需要注意的是max和min的单位是数据包个数而不是字节数。

avpkt:平均包长度。这个参数是用根burst参数一起来计算平均队列长度的参数,所以选择一个合适的值对整体效果的影响较大。一般的推荐值为1000。

burst:字面含义是队列可以容纳的爆发流量。但是我们知道,爆发流量的承载是根据队列容量上限(limit)决定的,当一个大于当前网络带宽处理能力的爆发流量来临时,不能及时发出的数据包将缓存在队列中,队列满了就会丢包。所以实际影响爆发流量承载能力的是limit参数。当然我们建议的limit长度应该是不少于max+burst的长度,这样才能有实际意义。但是这个参数将对平均队列长度的变化速度产生影响,可以想像,如果我们想要支持队列能处理尽可能大的爆发流量的话,当队列突然变长的时候,应该让平均队列长度的计算结果变化没那么敏感,这样爆发流量来的时候丢包的可能性会减小。所以,这个值设置的越高,那么平均队列长度的计算敏感度就约小,变化速度将会变慢,反之变快。

bandwidth:用于在网络空闲的时候计算平均队列长度的参数,应该配置成你的网络的实际带宽大小。并不是说RED有限速作用。

ecn:ecn实际上是TCP/IP协议用来通知网络拥塞所实现的一个数据报字段。添加这个参数标示意味着,当RED检测到拥塞都是通过标记数据包的ecn字段来通知数据源端减少数据发送量,并且在实际队列长度达到limit限制之前丢不会丢弃数据包。

adaptive:算是一种更智能的probability参数的选择,添加了这个参数之后就可以不用人为指定一个固定的probability了,当平均队列长度超过(max-min)/2时,RED会动态的根据情况让probability的值在1%到50%之间变化。具体描述参见这里

以上就是RED队列规则的配置方法和意义,其作用主要是缓解全局同步的问题。但是我们在实际使用的时候发现,RED的min、max、probability这些参数的选择在实际场景中可能会根据情况变化而改变才是最优的,但是RED的配置不能自适应这些变化。并且实际上在很多特定的网络负载下依然会导致TCP的全局同步。这些缺陷促使我们寻找更优秀的方式来解决这些问题。

内核还实现了另一个队列规则叫做choke,其所有配置参数跟RED完全一样,区别是,RED是通过字节为单位进行队列控制,而choke是以数据包为单位。更多帮助请:man tc-choke

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

###CoDel算法

CoDel算法是另一种AQM算法,其全称是Controlled Delay算法。是由Van Jacobson和Kathleen Nichols在2012年实现的。具体描述参见Controlling Queue Delay。CoDel采用了另外一种角度来观察队列满载的问题,其出发点并不是对队列长度进行控制,而是对队列中的数据包的驻留时间进行控制。事实上如果我们将管理方式由队列长度控制变成等待时间控制的时候,bufferbloat就可以彻底解决了,这也是更先进的AQM算法所用的方式。我们仔细观察bufferbloat问题,会发现,引起这个问题的重要原因就是数据包在队列中的驻留时间过长,超过了有效的处理时间(SLA定义的时间或者重试时间),导致处理到的数据包都已经超时。

首先我们根据我们的业务设计,确定出请求在队列中正常情况应该驻留多久。我们还是假定这样一种场景,根上面bufferbloat中描述的例子差不多:后端处理速度是1000次每秒,就是1ms可以处理一个请求,而队列平均长度一般为5,就是说一个新请求进入队列之后,发现前面还有5个请求在等待,那么这个新请求的处理时间大约为6ms(在队列中等待5ms)。那么请求在队列中的驻留时间正常情况下基本为5ms。而我们服务的SLA确定的时间是100ms(由诸如服务超时时间或者所在网络的最大RTT时间等条件确定),就是说,服务应确保在100ms内给出反馈,这个时间叫做interval time,如果超过这个时间应该返回失败。针对这样的情况,我们可以根据请求驻留时间的情况来描述一个动态长度的队列,当一个请求入队之后,对其驻留时间(sojourn time)进行追踪,以正常的情况作为其目标驻留时间(target time),在这个例子中是5ms,就是说一般情况下,我们期望请求在队列中的驻留时间不高于5ms。由于业务的超时时间或者说我们提供的SLA处理时间是100ms,所以,在这个队列中驻留超过100ms的请求都应该丢弃(从队列头开始),因为即使处理完成它们也没有意义了。丢弃将持续到队列中的请求等待时间回到理想的target time为止,并且队列长度整体不大于队列容量上限。这样就根据驻留时间维持了一个动态长度的队列,这个队列中的所有请求理论上都应该等待100ms以内,要么被正常处理掉,要么被丢弃。这就是CoDel算法的基本思路。

为了有助于大家理解,我们再详细一点描述一下这个算法的处理过程:

CoDel算法对队列状态维护一个状态机,进行队列dequeue处理的时候,先判检查队列头请求的驻留时间(sojourn time)是否大于target time,如果不大于target time,就直接dequeue;如果大于(target time)的请求维持了interval time这么长的时间,则队列应该进入dropping状态开始丢包。这种丢包状态将可能维持一段时间,这段时间的长度将根据情况而定(驻留时间一直处在target以上,并且下一个包丢弃的时间采用逆平方根运算(inverse-square-root),公式为:

t(第一次取now,以后取上次的值) + interval / sqrt(count))

count的取值为丢弃包的个数,如果count大于2则count=count-2,其他情况count取值为1。直到驻留时间小于target time,就退出dropping状态。

算法的伪代码描述参见这里

我们之所以要如此详细的描述bufferbloat问题以及其解决方案,尤其是CoDel算法,原因是其不仅仅被用在网络的分组交换和路由的处理上。除了TC的队列规则外,CoDel当前还被用在了内核TCP协议栈的拥塞控制中,并且rabbitmq也已经把这个算法应用于消息队列的延时控制了,参见。这个算法在数据中心的应用场景下,是一个非常好的解决队列阻塞的方案。

了解了以上知识之后,我们来看一下再Linux上如何配置一个CoDel的队列规则,我们刚才已经将队列规则改为RED了,此时如果要将其改为CoDel,需要先删除RED的队列规则,再添加新的队列规则:

[root@zorrozou-pc0 zorro]# tc qd del dev enp2s0 root
[root@zorrozou-pc0 zorro]# tc qdisc add dev enp2s0 root codel limit 100 target 4ms interval 30ms ecn
[root@zorrozou-pc0 zorro]# tc qd ls dev enp2s0
qdisc codel 8002: root refcnt 2 limit 100p target 4.0ms interval 30.0ms ecn
[root@zorrozou-pc0 zorro]# tc -s qd ls dev enp2s0
qdisc codel 8002: root refcnt 2 limit 100p target 4.0ms interval 30.0ms ecn
Sent 5546 bytes 39 pkt (dropped 0, overlimits 0 requeues 0)
backlog 0b 0p requeues 0
count 0 lastcount 0 ldelay 0us drop_next 0us
maxpacket 0 ecn_mark 0 drop_overlimit 0

tc的-s参数相信你已经明白什么意思了。来说一下codel队列规则的相关参数:

limit:队列长度上限,如果超过这个长度,新来的数据包将被直接丢弃。单位为字节数,默认值为1000.

target && interval:这两个参数相信大家已经明白是什么意思了,根据自己的场景进行配置就好了。

ecn && noecn:这个参数的含义根RED中的一样,默认是开启的ecn方式通知源端,不丢包。

大家也可以直接使用codel规则的默认参数,就是其他参数都省略即可。我们来看看什么效果:

[root@zorrozou-pc0 zorro]# tc qd del dev enp2s0 root
[root@zorrozou-pc0 zorro]# tc qdisc add dev enp2s0 root codel
[root@zorrozou-pc0 zorro]# tc -s qd ls dev enp2s0
qdisc codel 8003: root refcnt 2 limit 1000p target 5.0ms interval 100.0ms
Sent 8613 bytes 33 pkt (dropped 0, overlimits 0 requeues 0)
backlog 0b 0p requeues 0
count 0 lastcount 0 ldelay 0us drop_next 0us
maxpacket 0 ecn_mark 0 drop_overlimit 0

###fq-codel队列规则

在比较老版本的Linux内核上,由于当时还没实现基于CoDel算法的队列规则,所以一直使用的是pfifo_fast作为默认队列规则。作为一个简单的队列规则,pfifo_fast越来越不能适应Linux的发展需要。这个发展主要指的是Linux作为android系统的操作系统内核被广泛用在了手机等移动互联设备上。在移动互联网的场景下,网络延时问题变的更普遍,而导致网络上的bufferbloat问题变成了急需解决的问题。于是,CoDel的算法引入变的非常必要。CoDel算法虽然比较高质量的解决了bufferbloat问题,但是并没有解决多链接处理的公平性问题。这个公平性问题其实也比较好理解,因为网络有不同的传输要求,某些传输数据量很大,但是延时要求不大,某些则是数据量很小,但是延时要求很高(IP协议TOS字段所描述的情况)。如果各种链接占用同一个队列,那么数据量大的的连接势必数据包就更多,那么从概率上讲,这样的连接挤占队列的能力就更强。而主动队列管理一般都是以ecn或者丢包为手段的,如果丢弃的是那些延时要求较高的连接的数据包,又会对用户的服务质量感受造成很大的影响。所以,最好的办法就是实现一个针对每一个数据流(flow)公平的CoDel队列规则,就是fq-codel。

fq-codel叫做flow queue codel调度,因为其特点也被叫做fair queue codel(完全公平)。fq-codel为每个需要使用网络的flow创建一个单独的队列(实际上是默认实现1024个队列,使用五元组hash给相关flow选择一个队列),队列之间使用针对字节的DRR(Deficit Round Robin)调度算法进行调度。它的工作方式是跟踪每个队列的当前差额(deficit)的字节个数。这个差额的初始值可以用quantum参数指定。每当一个队列获得发送数据(出队)的机会时就开始发送数据包,并根据发送的数据包的字节数减少deficit的值,直到这个值变为负值的时候,对其增加一个quantum的大小,并且本队列发送结束,调度下一个队列。

这意味着,如果目前有两个队列,一个队列中的数据包长度都是quantum/3这么大,而另一个队列中的数据包长度每个都是一个quantum长度的话,调度器处理第一个队列的时候,每次处理3个数据包,而第二个队列就只能处理1个数据包。这意味着DRR算法对每个队列发送数据的时候是针对字节数计数,不会因为数据包数的大小而有差别。

quantum取值的大小决定了调度周期的粒度,所以也就决定了调度器的调度开销。当网络带宽比较小的时候,推荐的设置是从默认的MTU的值来取quantum的值,并可以考虑适当减小这个值。

不同于标准DRR调度的地方是,我们的调度器将所有flow队列分成了两个sets。实际上可以认为所有队列有两个分类,一类里面都是new flow,针对新建的网络连接;而另一类是old flow,针对原来机已经建立的网络连接。

Interval

这个值的意义根CoDel算法中的语义完全一样,是用来确定最小延时时间的取值不至于导致数据包长时间在队列里堆积。最小延时的取值必须根据上一个周期interval检查的经验而得来,应该被设置为,数据包通过网络瓶颈点发给对端之后,能够接收到对端返回的确认的最差RTT时间。

默认间隔时间值为100ms。

Target

这个值的意义根CoDel算法中的语义完全一样,是用来设定在FQ-CoDel的每个队列中数据包的最小延时时间(可以等待的最长时间)的。最小延时时间是通过追踪本地最小队列延时的经验得来的。

默认的Target值为5ms,但是这个值应该根据本地的网络情况得来,最少应配制成本地网络的mtu长度的数据包在相应的带宽环境下发送的时间。(如:本地网卡mtu为1500,带宽为1Mbps的情况下,应配置为15ms。)

下面简述一下fq-codel的处理过程:

FQ-CoDel的入队(enqueue)

入队由三个步骤组成:根据flow特点进行分类选择一个队列,记录数据包入队时间并记账(bookkeeping),另外如果队列满了还会丢弃数据包。

分类的时候会根据数据包的源、目的ip;源、目的端口和使用的协议(五元组)并参杂一个随机数,用这个值对队列个数取模运算,得出把这个flow放到哪个队列中。

FQ-CoDel的出队(dequeue)

队列规则的绝大多数工作都是在出队的时候做的。分三个步骤:选择从那个队列发送数据包;dequeue数据包(在所选队列中处理CoDel算法);记账(bookkeeping);

在第一部分处理的过程中:调度器先查找new list队列,对这个list中的每个队列进行处理处理,如果队列有负的赤字(negative deficit)说明起已经被出队了至少一个quantum的字节数,那么就说明这个队列已经不再是new队列了,则追加到old list中,并且给其增加一个quantum的字节数的deficit,然后处理new list中的下一个队列。

如果选择的队列不是上述情况,就说明这是一个new队列,则对其dequeue。如果new列表为空,则开始处理old列表,处理过程根上述过程类似。

选择好处理哪个queue之后,CoDel算法就会作用于这个队列。这个算法可能在返回需要dequque的数据包之前,先删除队列中的一个或者多个数据包,数据包的删除是从队列头开始的。

最后,如果CoDel没有返回需要dequeue的数据包,或者队列为空,调度器将根据情况做这两件事的其中一个:如果队列是new列表中的队列,则将其移动到old列表的最后一个。如果队列是old列表中的队列,那么这个队列讲从old列表中删除,直到下次这个队列中有数据包需要处理的时候,就再把它加到new列表中。如果所有队列中都没有需要dequeue的数据包之后,就对所有队列重来一次上述调度过程。

如果调度算法返回了一个需要dequeue的数据包,处理过程将会先去处理deficit数字,然后对数据包进行相关dequeue处理。

检查new列表并把符合条件的队列移动到old列表这个过程会因为可能存在的无限循环而导致饥饿。则是因为当某一个数据流符合一个速率进行小包发送的时候,这个队列会在new列表中重现,而导致调度器一直无法处理old列表。预防这种饥饿的方法是,在第一次讲队列移动到old列表的时候,强制跳过不再检查。

以上过程更详细的描述参见。我们再来看看如何配置一个fq-codel队列规则。跟刚才步骤类似:

[root@zorrozou-pc0 zorro]# tc qd del dev enp2s0 root
[root@zorrozou-pc0 zorro]# tc -s qd ls dev enp2s0
qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5.0ms interval 100.0ms ecn
Sent 7645 bytes 45 pkt (dropped 0, overlimits 0 requeues 0)
backlog 0b 0p requeues 0
maxpacket 0 drop_overlimit 0 new_flow_count 0 ecn_mark 0
new_flows_len 0 old_flows_len 0

其实我们会发现,作为默认的队列规则,删除了原来配置的队列规则之后,显示的就是fq-codel了,默认参数就是显示的这样了。这个队列规则包含的参数包括:

limit

flows

target

interval

quantum

ecn | noecn

帮助可以参见man tc-fq_codel。唯一需要再稍作解释的就是flows,这个参数决定了有少个队列,默认1024。

另外,内核还提供了一个fq队列,实际上就是fq-codel不带codel的一个基于DRR算法的公平队列。这里没有更多参考,你可以直接使用这个队列。

本节涉及到了一个负载均衡算法,DRR-基于赤字的轮训算法。实际上内核也实现了一个专门的DRR调度队列,大家可以参考man tc-drr。关于这个算法本身的描述请自行查找资料。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

###SFQ随机公平队列

首先我要引用LARTC中文版中对SFQ队列的讲解,毕竟这已经足够权威了:

SFQ(Stochastic Fairness Queueing,随机公平队列)是公平队列算法家族中的一个简单实现。它的精确性不如其它的方法,但是它在实现高度公平的同时,需要的
计算量却很少。SFQ 的关键词是“会话”(或称作“流”) ,主要针对一个 TCP 会话或者 UDP流。流量被分成相当多数量的 FIFO 队列中,每个队列对应一个会话。数据按照
简单轮转的方式发送, 每个会话都按顺序得到发送机会。这种方式非常公平,保证了每一个会话都不会没其它会话所淹没。SFQ 之所以被称为“随机”,是因为它并不是真的为每一个会话创建一个队列,而是使用一个散列算法,把所有的会话映射到有限的几个队列中去。因为使用了散列,所以可能多个会话分配在同一个队列里,从而需要共享发包的机会,也就是共享带宽。为了不让这种效应太明显,SFQ 会频繁地改变散列算法,以便把这种效应控制在几秒钟之内。有很重要的一点需要声明:只有当你的出口网卡确实已经挤满了的时候,SFQ 才会起作用!否则在你的 Linux 机器中根本就不会有队列,SFQ 也就不会起作用。稍后我们会描述如何把 SFQ 与其它的队列规定结合在一起,以保证两种情况下都比较好的结果。特别地,在你使用 DSL modem 或者 cable modem 的以太网卡上设置 SFQ 而不进行任何进一步地流量整形是无谋的!

SFQ 基本上不需要手工调整:
perturb:多少秒后重新配置一次散列算法。如果取消设置,散列算法将永远不会重新配置(不建议这样做)。10 秒应该是一个合适的值。
quantum:一个流至少要传输多少字节后才切换到下一个队列。却省设置为一个最大包的长度(MTU 的大小)。不要设置这个数值低于 MTU!

如果你有一个网卡,它的链路速度与实际可用速率一致——比如一个电话MODEM——如下配置可以提高公平性:

tc qdisc add dev ppp0 root sfq perturb 10

tc -s -d qdisc ls

qdisc sfq 800c: dev ppp0 quantum 1514b limit 128p flows 128/1024 perturb 10sec
Sent 4812 bytes 62 pkts (dropped 0, overlimits 0)

“800c:”这个号码是系统自动分配的一个句柄号,“limit”意思是这个队列中可以有 128 个数据包排队等待。一共可以有 1024 个散列目标可以用于速率审计,而其中 128 个可以同时激活。(no more packets fit in the queue!)每隔 10 秒种散列算法更换一次。

以上是对SFQ队列的权威解释,但是毕竟时过境迁,目前的实现稍有不同。现在的SFQ在原有队列的基础上实现了RED模式,就是针对每一个SFQ队列,都可以用RED算法来防止bufferbloat问题。目前的RED跟SFQ队列规则的关系有点像codel跟fq_codel队列规则之间的关系,它们一个是基础版算法的队列实现,另一个是其多队列版。

新版中需要解释的参数:

redflowlimit:用来限制在RED模式下的SFQ的每个队列的字节数上限。

perturb:默认值为0,表示不重新配置hash算法。原来为10,单位是秒。

depth:限制每一个队列的深度(长度),默认值127,只能减少,单位包个数。

如果需要配置一个RED模式的SFQ,操作方式如下:

tc qdisc add dev eth0 parent 1:1 handle 10: sfq limit 3000 flows 512 divisor 16384 redflowlimit 100000 min 8000 max 60000 probability 0.20 ecn headdrop

更多的帮助情参阅:

man tc-sfq

内核还给我们提供了一个名叫sfb的随机公平队列,相对sfq来说,sfb的意思就是采用的blue算法对每个队列进程处理。什么是blue算法?这是相对于red来说的(有红的算法,也要有蓝的)。我们不对BLUE算法做更详细的解释了,大家有兴趣可以自行查找资料。

SFQ的结构如下:

SFQ

###PIE比例积分控制队列

PIE是Proportional Integral controller Enhanced的简写,其中文名称是加强的比例积分控制。比例积分控制是非常有名的一种工控算法。想要详细了解这个方法的,可以自行查阅相关资料。而在tc的队列规则中,pie是内核帮我们实现的另一个用来解决bufferbloat问题的AQM机制。其控制思路跟CoDel一样,都是针对请求的延时进行控制而不是队列长度,但是其对超时请求处理方法跟RED一样,都是随机对数据包进行丢弃。

PIE是根据队列中请求的延时情况而对不同级别的拥塞做出相关的相应动作的(比如丢包),严格来说,是根据队列中请求延时时间的变化率(就是当前延时时间与目标延时时间的差值与时间的积分)来判断。这就能做到影响算法参数值选择是根据稳态感受的变化而变化的,目的就是可以让算法本身在各种网络阻塞的情况下都能自动调节以优化性能表现。

PIE包括三个简单的必需组件:1.入队时的随机丢弃;2.周期的更新丢弃可能性比率(probability);3.对延时(latency)进行计算。当一个请求到达队列时(入队之前),会被评估这个请求是否会被随机丢弃。丢弃的几率会根据目前的延时状态和目标延时(target)的差距(比例控制)以及队列的延时是否变长或者变短(积分控制)的状态,每隔一定时间周期(tupdate)进行更新。队列的延时是通过直接测量请求的等待时间或计算队列长度和出队速率获得的。

跟其他最先进的AQM算法一样,当一个数据包到达时PIE会根据一个随机丢弃的可能性p来丢弃数据包,p的计算方式如下:

  1. 首先根据以下公式估计当前队列延时:
    est_del = qlen/depart_rate;
  2. 计算丢弃可能性几率p:
    p = p + alpha * (est_del – target_del) + beta * (est_del – est_del_old);
    est_del_old = est_del.

以上计算过程会按一定时间周期进行估算,周期的时间由tupdate参数指定,est_del是当前周期的队列延时,est_del_old是上一个周期的队列延时,target_del是目标延时。qlen表示当前队列长度。

alpha是用来确定当前的延迟与目标延时的偏差将如何影响丢弃概率。beta值会对整个p的估算起到另一个校准作用,这个作用通过目前的延时是在上升还是在下降进行估算的。请注意p的运算是一个逐渐达到的过程(积分过程),并不是一步达到的。在运算p的时候,为了避免校准过程中比较大的波动,我们一般是对p做小的增量调整。假设p在1%的范围内,那么我们希望单步校准的幅度也比较小,比如0.1%,那么alpha和beta也都要足够小,。但是如果p的值更高了,比如说达到了10%,在这种情况下,我们的单步教准的幅度也希望更大,比如达到1%。所以我们在p取值的每一个量级范围内,都可能需要一个单步的调教幅度的取值范围,在必要的情况下p可能会精确到0.0001%。这个单步调校的范围可以通过类似这样一个方式实现:

if (drop_prob_ < 0.000001) {
drop_prob_ /= 2048;
} else if (drop_prob_ < 0.00001) {
drop_prob_ /= 512;
} else if (drop_prob_ < 0.0001) {
drop_prob_ /= 128;
} else if (drop_prob_ < 0.001) {
drop_prob_ /= 32;
} else if (drop_prob_ < 0.01) {
drop_prob_ /= 8;
} else if (drop_prob_ < 0.1) { drop_prob_ /= 2; } else { drop_prob_ = drop_prob_; } 对p进行调校的目标是让p稳定下来,稳定的条件就是当队列的当前延时等于目标延时,并且延时状态已经稳定的情况(就是说est_del等于est_del_old)。alpha和beta的取值实际上就是一个权重值,如果alpha较大则丢弃几率对延时偏移(latency offset即相对于目标延时的差距)更敏感,如果beta较大则丢弃几率p对延时抖动(latency jitter即相对于上周期延时的差距)更敏感。 计算周期tupdate参数也是一个让整个校准过程能够稳定发挥效果的重要参数,当我们配置更快的tupdate周期,并且alpha和beta的值相同时,则周期增益效果更明显。请注意alpha和beta的配置单位是hz,由于在上面的计算公式中表示的不明显,所以这可能会成为配置出错的地方。 请注意,丢弃可能性p的计算不仅与当前队列延时的估算有关,还与延时变化的方向有关,就是说,延时变大或者变小都会影响计算。延时变化的方向可以从当前队列延时和之前一个周期的队列延时进行比较来确定。这就是采用标准的比例积分控制算法对队列的延时进行控制。 队列的出队速率可能会经常波动,造成这种情况的原因是我们可能与其它队列共享同一个连接设备,活着链路的容量波动。在无线网络的情况下,链路的波动尤其常见。因此,我们通过以下方法直接测量出队速率: 当队列中有足够的数据时,才进入测量周期: qlen > dq_threshold

进入测量周期之后,在数据包出队时:
dq_count = dq_count + deque_pkt_size;

然后判断dq_coune是不是高于采样阈值:
if dq_count > dq_threshold then

depart_rate = dq_count/(now-start);

dq_count = 0;

start = now;

我们只在队列中存在足够的数据的时候才计算出队速率,就是当队列长度超过deq_threshold这个阈值的时候。这是因为时不时出现的短的和非持久性的爆发数据流量进入空队列时会使测量不准确。参数dq_count表示从上次测量之后离开的字节数,一旦这个值超过了deq_threshold阈值,我们就得到一次有效的测量采样。在数据包长度在1k到1.5k长度的时候,我们建议dq_count的值为16k,这样的设置既可以让我们有足够长的时间周期来对出队速率做平均,也能够足够快的反馈出出队速率的突然变化。这个阈值并不影响系统的稳定性。

除了上面的基本算法描述以外,PIE算法该提供了一些其它增强功能来提升算法的性能:

网络流量往往都会有一定的自然波动,当队列的延时因为这样的波动而出现临时性的“虚假”上涨的时候,我们不希望在这样的情况下引起不必要的丢包。所以,PIE算法实现了一个自动开启和关闭算法的机制,当队列长度不足缓冲区长度的1/3时,算法是不会生效的,此时处于关闭状态,当队列中的数据量超过了1/3这个阈值的时候,算法自动打开,开始对队列中的数据进行处理。当阻塞情况完全恢复的时候,就是说丢弃概率、队列长度和队列延时都为0的时候,PIE的作用关闭。

虽然PIE采用随机丢弃的策略来处理入队的数据包,但是仍然可能会有几率因为丢弃的数据包很连续或者很稀疏而导致丢弃效果偏离丢弃几率p。这就好比抛硬币问题,虽然概率上出现正面或者反面的几率都是50%,但是当你真的去抛硬币的时候,仍然可能碰见连续多次的出现正面或者反面的情况。所以,我们引入了一种“去随机”的丢弃机制来防止这样的事情发生。我们引入了一个参数prob,当发生丢弃的时候,这个参数被重置为0,当数据包到达进行丢弃判断的时候,prob参数也会进行累加,累加的值是每次计算丢弃概率得到p这个值的总量。prob会有一个阈值下限和一个上限,当累计的prob低于阈值下线的时候,我们不丢包,直接入队,当高于阈值上限的时候,我们无论几率如何,强制丢包。只有当prob在阈值下限和上限之间时,我们才按照p的几率丢弃数据包。这样就能保证,如果几率导致连续没丢包,积累到一定程度后一定会丢包,另一方面,如果丢包,则prob一定在下限以下,则下一个包一定会入队,以防止问题的发生。

关于PIE更多的资料,可以参考这里

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

###TBF队列

以上的算法主要都是解决bufferbloat问题的。我们可以看到Linux内核为了适应移动互联网的环境做了很多努力。而接下来我们要介绍的TBF(令牌痛过滤器)是我们遇到的第一个可以对流量进行整形(就是限速)的算法。自它诞生到现在,基本功能没有什么太大变化,毕竟token bucket filter算法已经是一个非常经典的限速算法了。所以我们只需要引用LARTC中的讲解即可:


令牌桶过滤器(TBF)是一个简单的队列规定:只允许以不超过事先设定的速率到来的数据包通过,但可能允许短暂突发流量朝过设定值。TBF 很精确,对于网络和处理器的影响都很小。所以如果您想对一个网卡限速,它应该成为您的第一选择!TBF 的实现在于一个缓冲器(桶),不断地被一些叫做“令牌”的虚拟数据以特定速率填充着。 (token rate)。桶最重要的参数就是它的大小,也就是它能够存储令牌的数量。每个到来的令牌从数据队列中收集一个数据包,然后从桶中被删除。这个算法关联到两个流上——令牌流和数据流,于是我们得到 3 种情景:• 数据流以等于令牌流的速率到达 TBF。这种情况下,每个到来的数据包都能对应一个令牌,然后无延迟地通过队列。

• 数据流以小于令牌流的速度到达 TBF。通过队列的数据包只消耗了一部分令牌,剩下的令牌会在桶里积累下来,直到桶被装满。剩下的令牌可以在需要以高于令牌流速率发送数据流的时候消耗掉,这种情况下会发生突发传输。

• 数据流以大于令牌流的速率到达 TBF。这意味着桶里的令牌很快就会被耗尽。导致 TBF 中断一段时间,称为“越限”。如果数据包持续到来,将发生丢包。

最后一种情景非常重要,因为它可以用来对数据通过过滤器的速率进行整形。令牌的积累可以导致越限的数据进行短时间的突发传输而不必丢包,但是持续越限的话会导致传输延迟直至丢包。

请注意,实际的实现是针对数据的字节数进行的,而不是针对数据包进行的。

即使如此,你还是可能需要进行修改,TBF 提供了一些可调控的参数。第一个参数永远可用:

limit/latency

limit 确定最多有多少数据(字节数)在队列中等待可用令牌。你也可以通过设置 latency 参数来指定这个参数,latency 参数确定了一个包在 TBF中等待传输的最长等待时间。后者计算决定桶的大小、速率和峰值速率。

burst/buffer/maxburst

桶的大小,以字节计。这个参数指定了最多可以有多少个令牌能够即刻被使用。通常,管理的带宽越大,需要的缓冲器就越大。在 Intel 体系上,10 兆 bit/s 的速率需要至少 10k 字节的缓冲区才能达到期望的速率。如果你的缓冲区太小,就会导致到达的令牌没有地方放(桶满了),这会导致潜在的丢包。

mpu

一个零长度的包并不是不耗费带宽。比如以太网,数据帧不会小于 64 字节。Mpu(Minimum Packet Unit,最小分组单位)决定了令牌的最低消耗。

rate

速度操纵杆。参见上面的 limits!如果桶里存在令牌而且允许没有令牌,相当于不限制速率(缺省情况)。If the bucket contains tokens and is allowed to empty, by default it does so at infinite speed. 如果不希望这样,可以调整入下参数:

peakrate

如果有可用的令牌,数据包一旦到来就会立刻被发送出去,就象光速一样。那可能并不是你希望的,特别是你有一个比较大的桶的时候。峰值速率可以用来指定令牌以多块的速度被删除。用书面语言来说,就是:释放一个数据包,但后等待足够的时间后再释放下一个。我们通过计算等待时间来控制峰值速率然而,由于 UNIX 定时器的分辨率是10 毫秒,如果平均包长 10k bit,我们的峰值速率被限制在了 1Mbps。

mtu/minburst

但是如果你的常规速率比较高,1Mbps 的峰值速率对我们就没有什么价值。要实现更高的峰值速率,可以在一个时钟周期内发送多个数据包。最有效的办法就是:再创建一个令牌桶!这第二个令牌桶缺省情况下为一个单个的数据包,并非一个真正的桶。要计算峰值速率,用 mtu 乘以 100 就行了。 (应该说是乘以 HZ 数,Intel体系上是 100,Alpha 体系上是 1024)

这是一个非常简单而实用的例子:

tc qdisc add dev ppp0 root tbf rate 220kbit latency 50ms burst 1540

为什么它很实用呢?如果你有一个队列较长的网络设备,比如 DSL modem 或者cable modem 什么的,并通过一个快速设备(如以太网卡)与之相连,你会发现上载数据绝对会破坏交互性。这是因为上载数据会充满 modem 的队列,而这个队列为了改善上载数据的吞吐量而设置的特别大。但这并不是你需要的,你可能为了提高交互性而需要一个不太大的队列。也就是说你希望在发送数据的时候干点别的事情。上面的一行命令并非直接影响了 modem 中的队列,而是通过控制 Linux 中的队列而放慢了发送数据的速度。把 220kbit 修改为你实际的上载速度再减去几个百分点。如果你的 modem 确实很快,就把“burst”值提高一点。


以上为引用原文内容,请原谅我的懒惰。TBF结构图如下:

TBF

###分类(class)、过滤器(filter)以及HTB

基于目前我们已经知道的这些内容,我们已经可以在一个运行着比较复杂的网络服务的系统环境中按照网络的数据流为调度对象,建立一个比较公平的队列环境了,并且还能避免bufferbloat现象。比如fq-codel、sfq等队列规则都能做到。这也是内核目前选择fq-codel作为默认队列规则的初衷。实际上这已经可以适应绝大多数场景了。

但是在一些QoS要求更高的场景中,我们可能需要对网络流量的服务做更细节的分类,来实现更多的功能。比如说我们有这样一个场景:我们的服务器上运行了一个web服务,对外服务端口是tcp的80,还运行了一个邮件服务,对外服务协议是smtp的tcp的25端口,可能还会开一个sshd以便管理员可以远程控制,其端口为22。我们的对外带宽一共为10Mbps。我们想要做到这样一种效果,当所有服务都很繁忙的需要占用带宽时,我们希望80端口上限不超过6Mbps,25端口上限不超过3Mbps,而22端口1Mbps足够了。当其它端口不忙的时候,某个端口可以突破自己的上限带宽设置能达到10Mbps的带宽。这种网络资源分配策略跟cgroup的cpushare方式的分配概念类似。

当我们的需求负载到类似这样的程度时,我们会发现以上的各种队列规则都不能满足需求,而能满足需求的队列规则都起吗必需实现一个功能,就是对数据包的分类(class)功能,并且这个分类要能够人为指定分类策略(实际上pfifo_fast本身对数据包进行了分类,但是并不能人为改变分类策略,所以我们仍然把它当成不可分类的队列规则)。比如针对当前的例子,我们就至少需要三个分类(可以认为就是三个队列),然后把从80端口发出的数据包都排进分类1里,从25端口发出的数据包排进分类2里,再将22端口发出的数据包放到分类3里。当然如果你的服务器还有别的服务也要用网络,可能还要额外配置一个分类或者共用以上某一个分类。

在这个描述中,我们会发现,当需求确定了,分类也就可以确定了,并且如何进行分类(过滤方法)也就可以确定了。如果我们还是把数据包比作去公交车站排队的人的话,那么可分类的队列规则就相当于公交站有个管理人员,这个管理人员可以根据情况自行确定目前乘客可以排几个队、哪个队排什么样特征的人。自然,我们需要在每个乘客来排队之前,根据确定好的策略对乘客进行过滤,让相关特征的乘客去相应的队伍。这个决定乘客所属分类的人就是过滤器(filter)。

以上是我对这两个概念的描述,希望能够帮助大家理解。相关概念的官方定义在此

由于相关知识的细节说明在LARTC中已经有了更细节的说明,我们再次不在废话。我们直接来看使用HTB(分层令牌桶)队列规则如何实现上述功能,其实无非就是以下系列命令:

首先,我们需要先讲当前网卡的队列规则换成HTB,保险起见,可以先删除当前队列规则再添加:

[root@zorrozou-pc0 zorro]# tc qd del dev enp2s0 root [root@zorrozou-pc0 zorro]# tc qd add dev enp2s0 root handle 1: htb default 30

default参数的含义就是,默认数据包都走标记为30的类(class)。
然后我们开始建立分类,并对各种分类进行限速:

[root@zorrozou-pc0 zorro]# tc cl add dev enp2s0 parent 1: classid 1:1 htb rate 10mbit burst 20k
[root@zorrozou-pc0 zorro]# tc cl add dev enp2s0 parent 1:1 classid 1:10 htb rate 6mbit ceil 10mbit burst 20k
[root@zorrozou-pc0 zorro]# tc cl add dev enp2s0 parent 1:1 classid 1:20 htb rate 3mbit ceil 10mbit burst 20k
[root@zorrozou-pc0 zorro]# tc cl add dev enp2s0 parent 1:1 classid 1:30 htb rate 1mbit ceil 10mbit burst 20k

这样我们建立好了一个root分类,id为1:1,速率上限为10mbit。然后在这个分类下建立了三个子分类,id分别为1:10、1:20、1:30,这个10、20、30的编号就是针对上面default的参数,你想让默认数据流走哪个分类,就在default参数后面加上它相应的id即可。我们建立了分类并且给分类做了速度限制,并且使用ceil参数指定每个分类都可以在其它分类空闲的时候借用带宽资源最高可以达到10mbit。

之后是给每个分类下再添加相应的过滤器,我们这里分别给三个分类使用了不同的过滤器,以实现不同的Qos保障。当然,每个子分类下还可以继续添加htb过滤器,让整个htb的分层树形结构变的更大,分类更细。一般情况下,两层的结构足以应付绝大多数场景了。

[root@zorrozou-pc0 zorro]# tc qd add dev enp2s0 parent 1:10 handle 10: fq_codel
[root@zorrozou-pc0 zorro]# tc qd add dev enp2s0 parent 1:20 handle 20: sfq
[root@zorrozou-pc0 zorro]# tc qd add dev enp2s0 parent 1:30 handle 30: pie

最后,我们使用u32过滤器,对数据包进行过滤,这两条命令分别将源端口为80的数据包放到分类1:10里,源端扣为25的数据包放到分类1:20里。默认其它数据包(包括22),根据default规则走分类1:30。

[root@zorrozou-pc0 zorro]# tc fi add dev enp2s0 protocol ip parent 1:0 prio 1 u32 match ip dport 80 0xffff flowid 1:10
[root@zorrozou-pc0 zorro]# tc fi add dev enp2s0 protocol ip parent 1:0 prio 1 u32 match ip dport 25 0xffff flowid 1:20

至此,htb以及u32过滤器的简单使用介绍完毕。

内核除了实现了u32过滤器来帮我们过滤数据包以外,还有一个常用的过滤器叫fw,就是实用防火墙标记作为数据包分类的区分方法(firewall mark)。我们可以先使用iptable的mangle表对数据包先做mark标记,然后在tc中使用fw过滤器去识别相应的数据包,并进行分类。还是用以上的例子进行说明,此时我们使用fw过滤器的话,最后两条命令将变成这样:

[root@zorrozou-pc0 zorro]# tc fi add dev enp2s0 protocol ip parent 1:0 prio 1 handle 1 fw flowid 1:10
[root@zorrozou-pc0 zorro]# tc fi add dev enp2s0 protocol ip parent 1:0 prio 1 handle 2 fw flowid 1:20

这两条命令说明,凡是被fwmark标记为1的数据包都走分类1:10,标记为2的走分类1:20。之后,别忘了在iptable里面添加对数据包的标记:

[root@zorrozou-pc0 zorro]# iptables -t mangle -A OUTPUT -p tcp –sport 80 -j MARK –set-mark 1
[root@zorrozou-pc0 zorro]# iptables -t mangle -A OUTPUT -p tcp –sport 25 -j MARK –set-mark 2

如果你不想学习u32过滤器哪些复杂的语法,那么fwmark是一种很好的替代方式。当然前提是你对iptables和tcp/ip协议有一定了解。

思考题:
添加完iptables规则后,我们可以通过以下命令查看目前mangle表的内容:

[root@zorrozou-pc0 zorro]# iptables -t mangle -L
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
Chain INPUT (policy ACCEPT)
target prot opt source destination
Chain FORWARD (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
MARK tcp — anywhere anywhere tcp spt:http MARK set 0x1
MARK tcp — anywhere anywhere tcp spt:smtp MARK set 0x2
Chain POSTROUTING (policy ACCEPT)
target prot opt source destination

在本例中,我们使用了其中的OUTPUT链添加了规则。那么问题是:使用不同的链(Chain)的区别是什么?

因为一些原因,我们推荐使用HTB的方式对比较复杂的网络数据包进行分类并流量整形。当然,可分类的队列规则中,除了HTB还有PRIO以及非常著名的CBQ。其中CBQ尤其在网络设备的限速方面有着最广泛的使用,但是如果从软件实现的角度来说,令牌桶方式(htb就是分层令牌桶)的流量限制在性能和稳定性上都更具有优势。PRIO由于分类过于简单,并不适合更复杂的场景。

关于这些知识的介绍,大家依然可以在LARTC上找到更详细的讲解。根据上面的命令,我们再参照结构图来理解一下HTB:

HTB

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

###使用cgroup限制网络流量

如果你是从头开始看到这里的,那么真的很佩服你的耐心。我们前面似乎讲了一堆跟cgroup做网络资源隔离没有关系的知识,但是无疑每一个知识点的理解对于我们规划网络的资源隔离都有很重要的作用。毕竟,我么要规划一个架构,必需了解清楚其相关实现以及要解决的问题。但是很不幸,我们依然没有能够讲完目前所有的qdisc实现,比如还有HFSC、ATM、MULTIQ、TEQL、GRED、DSMARK、MQPRIO、QFQ、HHF等,这些还是留着给大家自己去解密吧。相信大家如果真正理解了队列规则要解决的问题和其基础知识,理解这些东西并不难。

最后,我们要来看看如何在cgroup的场景下对网络资源进行隔离了。实际上跟我们上面讲的HTB的例子类似,区别是,上面的例子是通过端口分类,而现在需要通过cgroup进行分类。我们还是通过一个例子来说明一下场景,并实现其功能:我们假定现在有两个cgroup,一个叫jerry,另一个叫zorro。我们现在需要给jerry组中运行的网络程序限制带宽为10mbit,zorro组的网路资源占用为20mbit,总带宽为100mbit,并且不允许借用(ceil)网络资源。那么配置思路是这样:

我们的配置环境是一台centos7的虚拟机,首先,我们在这个服务器上运行一个apache的http服务,并发布了一个1G的数据文件作为测试文件,并在不限速的情况下对齐进行下载速度测试,结果为100MBps,注意这里的速度是byte而不是bit:

zorrozou-nb:~ zorro$ curl -O http://192.168.139.136/file
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 1024M 100 1024M 0 0 101M 0 0:00:10 0:00:10 –:–:– 100M

之后我们在centos7(192.168.139.136)上实现三个分类,一个带宽限制10m给jerry,另一个20m给zorro,还有一个为30m用作default,总带宽100m,剩余的资源给以后可能新加入的cgroup来分配,于是先建立相关的规则和分类:

[root@localhost Desktop]# tc qd add dev eno16777736 root handle 1: htb default 100
[root@localhost Desktop]# tc cl add dev eno16777736 parent 1: classid 1:1 htb rate 100mbit burst 20k
[root@localhost Desktop]# tc cl add dev eno16777736 parent 1:1 classid 1:10 htb rate 10mbit burst 20k
[root@localhost Desktop]# tc cl add dev eno16777736 parent 1:1 classid 1:20 htb rate 20mbit burst 20k
[root@localhost Desktop]# tc cl add dev eno16777736 parent 1:1 classid 1:100 htb rate 30mbit burst 20k

[root@localhost Desktop]# tc qd add dev eno16777736 parent 1:10 handle 10: fq_codel
[root@localhost Desktop]# tc qd add dev eno16777736 parent 1:20 handle 20: fq_codel
[root@localhost Desktop]# tc qd add dev eno16777736 parent 1:100 handle 100: fq_codel

建立完分类之后,由于默认情况都要走1:100的分类,所以限速应该是30mbit,验证一下:

zorrozou-nb:~ zorro$ curl -O http://192.168.139.136/file
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 1024M 0 3484k 0 0 3452k 0 0:05:03 0:00:01 0:05:02 3452k

当前速度为3452kB左右,大概为30mbit,符合预期。之后将我们的http服务放到zorro组中看看效果,当然是首先建立相关cgroup以及相关配置:

[root@localhost Desktop]# ls /sys/fs/cgroup/net_cls/
cgroup.clone_children cgroup.event_control cgroup.procs cgroup.sane_behavior net_cls.classid notify_on_release release_agent tasks
[root@localhost Desktop]# mkdir /sys/fs/cgroup/net_cls/zorro
[root@localhost Desktop]# mkdir /sys/fs/cgroup/net_cls/jerry
[root@localhost Desktop]# ls /sys/fs/cgroup/net_cls/{zorro,jerry}
/sys/fs/cgroup/net_cls/jerry:
cgroup.clone_children cgroup.event_control cgroup.procs net_cls.classid notify_on_release tasks

/sys/fs/cgroup/net_cls/zorro:
cgroup.clone_children cgroup.event_control cgroup.procs net_cls.classid notify_on_release tasks

建立完毕之后分别配置相关的cgroup,将对应cgroup产生的数据包对应到相应的分类中,配置方法:

[root@localhost Desktop]# echo 0x00010100 > /sys/fs/cgroup/net_cls/net_cls.classid
[root@localhost Desktop]# echo 0x00010010 > /sys/fs/cgroup/net_cls/jerry/net_cls.classid
[root@localhost Desktop]# echo 0x00010020 > /sys/fs/cgroup/net_cls/zorro/net_cls.classid
[root@localhost Desktop]# tc fi add dev eno16777736 parent 1: protocol ip prio 1 handle 1: cgroup

这里的tc命令是对filter进行操作,这里我们使用了cgroup过滤器,来实现将cgroup的数据包送到1:0分类中,细节不再解释。对于net_cls.classid文件,我们一般echo的是一个0xAAAABBBB的值,AAAA对应class中:前面的数字,而BBBB对应后面的数字,如:0x00010100就表示这个组的数据包将被分类到1:100中,限速为30mbit,以此类推。之后我们把http服务放倒jerry组中看看效果:

[root@localhost Desktop]# for i in ps ax|grep httpd|awk '{ print $1}';do echo $i > /sys/fs/cgroup/net_cls/jerry/tasks;done
bash: echo: write error: No such process
[root@localhost Desktop]# cat /sys/fs/cgroup/net_cls/jerry/tasks
75733
75734
75735
75736
75737
75738
75777
75778
75779

测试效果:

zorrozou-nb:~ zorro$ curl -O http://192.168.139.136/file
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 1024M 0 5118k 0 0 1162k 0 0:15:01 0:00:04 0:14:57 1162k

确实限速在了10mbitps。成功达到效果,再来看看放倒zorro组下:

[root@localhost Desktop]# for i in ps ax|grep httpd|awk '{ print $1}';do echo $i > /sys/fs/cgroup/net_cls/zorro/tasks;done
bash: echo: write error: No such process
[root@localhost Desktop]# cat /sys/fs/cgroup/net_cls/zorro/tasks
75733
75734
75735
75736
75737
75738
75777
75778
75779
再次测试效果:

zorrozou-nb:~ zorro$ curl -O http://192.168.139.136/file
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 1024M 0 5586k 0 0 2334k 0 0:07:29 0:00:02 0:07:27 2334k

限速20mbps成功。如果想要修改对于一个分类的限速,使用如下命令即可:

tc cl change dev eno16777736 parent 1: classid 1:100 htb rate 100mbit

关于命令参数的详细解释,这里不做过多说明了。大家可以自行找帮助。

###最后

终于,我的Cgroup系列四部曲算是告一段落了。实际上Linux的Cgroup除了CPU、内存、IO和网络的资源管理以外,还有一些其它的配置,比如针对设备文件的访问控制和freezer机制等功能,但是这些功能都相对比较简单,个人认为没必要过多介绍了,大家要用的时候自己找帮助即可。
最后的最后,还是奉送一张Linux网络相关的数据包处理流程图,从这张图上大家可以清晰的看到qdisc的作用位置和其根iptables的作用关系。原图链接在此

Netfilter-packet-flow

 

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

Cgroup – Linux的IO资源隔离

Cgroup – Linux的IO资源隔离

Zorro] icon

Hi,我是Zorro。微信公众号:Linux系统技术。这是我的微博地址,我会不定期在这里更新文章,如果你有兴趣,可以来关注我呦。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

今天我们来谈谈:

##Linux的IO隔离

跟内存管理那部分复杂度类似,IO的资源隔离要讲清楚也是比较麻烦的。这部分内容都是这样,配置起来简单,但是要理解清楚确没那么简单。这次是跟Linux内核的IO实现有关系。对于IO的速度限制,实现思路跟CPU和内存都不一样。CPU是针对进程占用时间的比例限制,内存是空间限制,而当我们讨论IO资源隔离的时候,实际上有两个资源需要考虑,一个是空间,另一个是速度。对于空间来说,这个很简单,大不了分区就是了。现实手段中,分区、LVM、磁盘配额、目录配额等等,不同的分区管理方式,不同的文件系统都给出了很多不同的解决方案。所以,空间的限制实际上不是cgroup要解决的问题,那就是说,我们在这里要解决的问题是:如何进行IO数据传输的速度限制。

限速这件事情,现实中有很多模型、算法去解决这个问题。比如,如果我们想控制高速公路上的汽车单位时间通过率,就让收费站每隔固定时间周期只允许通过固定个数的车就好了。这是一种非常有效的控制手段--漏斗算法。现实中这种算法可能在特定情况下会造成资源浪费以及用户的体验不好,于是又演化出令牌桶算法。这里我们不去详细分析这些算法,但是我们要知道,对io的限速基本是一个漏斗算法的限速效果。无论如何,这种限速都要有个“收费站”这样的设施来执行限速,那么对于Linux的IO体系来说,这个”收费站”建在哪里呢?于是我们就必须先来了解一下:

###Linux的IO体系

Linux的IO体系是个层级还算清晰的结构,它基本上分成了如图示这样几层:

Linux的IO体系层次结构

Linux的IO体系层次

我们可以通过追踪一个read()系统调用来一窥这些层次的结构,当read()系统调用发生,内核首先会通过汇编指令引发一个软中断,然后根据中断传入的参数查询系统调用影射表,找到read()对应的内核调用方法名,并去执行相关调用,这个系统调用名一般情况下就是sys_read()。从此,便开始了调用在内核中处理的过程的第一步:

  1. VFS层:虚拟文件系统层。由于内核要跟多种文件系统打交道,而每一种文件系统所实现的数据结构和相关方法都可能不尽相同,所以,内核抽象了这一层,专门用来适配各种文件系统,并对外提供统一操作接口。
  2. 文件系统层:不同的文件系统实现自己的操作过程,提供自己特有的特征,具体不多说了,大家愿意的话自己去看代码即可。
  3. 页缓存层:我们的老朋友了,如果不了解缓存是什么的,可以先来看看Linux内存资源管理部分。
  4. 通用块层:由于绝大多数情况的io操作是跟块设备打交道,所以Linux在此提供了一个类似vfs层的块设备操作抽象层。下层对接各种不同属性的块设备,对上提供统一的Block IO请求标准。
  5. IO调度层:因为绝大多数的块设备都是类似磁盘这样的设备,所以有必要根据这类设备的特点以及应用的不同特点来设置一些不同的调度算法和队列。以便在不同的应用环境下有针对性的提高磁盘的读写效率,这里就是大名鼎鼎的Linux电梯所起作用的地方。针对机械硬盘的各种调度方法就是在这实现的。
  6. 块设备驱动层:驱动层对外提供相对比较高级的设备操作接口,往往是C语言的,而下层对接设备本身的操作方法和规范。
  7. 块设备层:这层就是具体的物理设备了,定义了各种真对设备操作方法和规范。

根据这几层的特点,如果你是设计者,你会在哪里实现真对块设备的限速策略呢?6、7都是相关具体设备的,如果在这个层次提供,那就不是内核全局的功能,而是某些设备自己的feture。文件系统层也可以实现,但是如果要全局实现也是不可能的,需要每种文件系统中都实现一遍,成本太高。所以,可以实现限速的地方比较合适的是VFS、缓存层、通用块层和IO调度层。而VFS和page cache这样的机制并不是面向块设备设计的,都是做其他事情用的,虽然也在io体系中,但是并不适合用来做block io的限速。所以这几层中,最适合并且成本最低就可以实现的地方就是IO调度层和通用块层。IO调度层本身已经有队列了,我们只要在队列里面实现一个限速机制即可,但是在IO调度层实现的限速会因为不同调度算法的侧重点不一样而有很多局限性,从通用块层实现的限速,原则上就可以对几乎所有的块设备进行带宽和iops的限制。截止目前(4.3.3内核),IO限速主要实现在这两层中。

根据IO调度层和通用块层的特点,这两层分别实现了两种不同策略的IO控制策略,也是目前blkio子系统提供的两种控制策略,一个是权重比例方式的控制,另一个是针对IO带宽和IOPS的控制。

###IO调度层

我们需要先来认识一下IO调度层。这一层要解决的核心问题是,如何提高块设备IO的整体性能?这一层也主要是针对用途最广泛的机械硬盘结构而设计的。众所周知,机械硬盘的存储介质是磁介质,并且是盘状,用磁头在盘片上移动进行数据的寻址,这类似播放一张唱片。这种结构的特点是,顺序的数据读写效率比较理想,但是如果一旦对盘片有随机读写,那么大量的时间都会浪费在磁头的移动上,这时候就会导致每次IO的响应时间很长,极大的降低IO的响应速度。磁头在盘片上寻道的操作,类似电梯调度,如果在寻道的过程中,能把路过的相关磁道的数据请求都“顺便”处理掉,那么就可以在比较小影响响应速度的前提下,提高整体IO的吞吐量。所以,一个好的IO调度算法的需求就此产生。在最开始的阶段,Linux就把这个算法命名为Linux电梯算法。目前在内核中默认开启了三种算法,其实严格算应该是两种,因为第一种叫做noop,就是空操作调度算法,也就是没有任何调度操作,并不对io请求进行排序,仅仅做适当的io合并的一个fifo队列。

目前内核中默认的调度算法应该是cfq,叫做完全公平队列调度。这个调度算法人如其名,它试图给所有进程提供一个完全公平的IO操作环境。它为每个进程创建一个同步IO调度队列,并默认以时间片和请求数限定的方式分配IO资源,以此保证每个进程的IO资源占用是公平的,cfq还实现了针对进程级别的优先级调度,这里我们不去细节解释。我们在此只需要知道,既然时间片分好了,优先级实现了,那么cfq肯定是实现进程级别的权重比例分配的最好方案。内核就是这么做的,cgroup blkio的权重比例限制就是基于cfq调度器实现的。如果你要使用权重比例分配,请先确定对应的块设备的IO调度算法是cfq。

查看和修改的方法是:

[zorro@zorrozou-pc0 ~]$ cat /sys/block/sda/queue/scheduler
noop deadline [cfq]
[zorro@zorrozou-pc0 ~]$ echo cfq > /sys/block/sda/queue/scheduler

cfq是通用服务器比较好的IO调度算法选择,对桌面用户也是比较好的选择。但是对于很多IO压力较大的场景就并不是很适应,尤其是IO压力集中在某些进程上的场景。因为这种场景我们需要更多的满足某个或者某几个进程的IO响应速度,而不是让所有的进程公平的使用IO,比如数据库应用。

deadline调度(最终期限调度)就是更适应这样的场景的解决方案。deadline实现了四个队列,其中两个分别处理正常read和write,按扇区号排序,进行正常io的合并处理以提高吞吐量.因为IO请求可能会集中在某些磁盘位置,这样会导致新来的请求一直被合并,于是可能会有其他磁盘位置的io请求被饿死。于是实现了另外两个处理超时read和write的队列,按请求创建时间排序,如果有超时的请求出现,就放进这两个队列,调度算法保证超时(达到最终期限时间)的队列中的请求会优先被处理,防止请求被饿死。由于deadline的特点,无疑在这里无法区分进程,也就不能实现针对进程的io资源控制。

其实不久前,内核还是默认标配四种算法,还有一种叫做as的算法(Anticipatory scheduler),预测调度算法。一个高大上的名字,搞得我一度认为Linux内核都会算命了。结果发现,无非是在基于deadline算法做io调度的之前等一小会时间,如果这段时间内有可以合并的io请求到来,就可以合并处理,提高deadline调度的在顺序读写情况下的数据吞吐量。其实这根本不是啥预测,我觉得不如叫撞大运调度算法。估计结果是不仅没有提高吞吐量,还降低了响应速度,所以内核干脆把它从默认配置里删除了。毕竟Linux的宗旨是实用。

根据以上几种io调度算法的简单分析,我们也能对各种调度算法的使用场景有一些大致的思路了。从原理上看,cfq是一种比较通用的调度算法,是一种以进程为出发点考虑的调度算法,保证大家尽量公平。deadline是一种以提高机械硬盘吞吐量为思考出发点的调度算法,只有当有io请求达到最终期限的时候才进行调度,非常适合业务比较单一并且IO压力比较重的业务,比如数据库。而noop呢?其实如果我们把我们的思考对象拓展到固态硬盘,那么你就会发现,无论cfq还是deadline,都是针对机械硬盘的结构进行的队列算法调整,而这种调整对于固态硬盘来说,完全没有意义。对于固态硬盘来说,IO调度算法越复杂,效率就越低,因为额外要处理的逻辑越多。所以,固态硬盘这种场景下,使用noop是最好的,deadline次之,而cfq由于复杂度的原因,无疑效率最低。但是,如果你想对你的固态硬盘做基于权重比例的IO限速的话,那就没啥办法了,毕竟这时候,效率并不是你的需求,要不你限速干嘛?

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

###通用块设备层

这层的作用我这里就不再复述了,本节其实主要想解释的是,既然这里实现了对blkio的带宽和iops的速度限制,那么有没有什么需要注意的地方?这自然是有的。首先我们还是要先来搞清楚IO中的几个概念。

一般IO

一个正常的文件io,需要经过vfs -> buffer\page cache -> 文件系统 -> 通用块设备层 -> IO调度层 -> 块设备驱动 -> 硬件设备这所有几个层次。其实这就是一般IO。当然,不同的状态可能会有变化,比如一个进程正好open并read一个已经存在于page cache中的数据。这样的事情我们排出在外不分析。那么什么是比较特别的io呢?

Direct IO

中文也可以叫直接IO操作,其特点是,VFS之后跳过buffer\page cache层,直接从文件系统层进行操作。那么就意味着,无论读还是写,都不会进行cache。我们基本上可以理解这样的io看起来效率要低很多,直接看到的速度就是设备的速度,并且缺少了cache层对数据的缓存之后,文件系统和数据块的操作效率直接暴露给了应用程序,块的大小会直接影响io速度。

Sync IO & write-through:

中文叫做同步IO操作,如果是写操作的话也叫write-through,这个操作往往容易跟上面的DIO搞混,因为看起来他们速度上差不多,但是是有本质区别的。这种方式写的数据要等待存储写入返回才能成功返回,所以跟DIO效率差不多,但是,写的数据仍然是要在cache中写入的,这样其他一般IO的程度仍然可以使用cache机制加速IO操作。所以,这里的sync的意思就是,在执行write操作的时候,让cache和存储上的数据一致。那么他跟一般IO其实一样,数据是要经过cache层的。

write-back:

既然明白了write-thuough,那么write-back就好理解了,无非就是将目前在cache中还没写回存储的脏数据写回到存储。这个名词一般指的是一个独立的过程,这个过程不是随着应用的写而发生,这往往是内核自己找个时间来单独操作的。说白了就是,应用写文件,感觉自己很快写完了,其实内核都把数据放倒cache里了,然后内核自己找时间再去写回到存储上。实际上write-back只是在一般IO的情况下,保证数据一致性的一种机制而已。

有人将IO过程中,以是否使用缓冲(缓存)的区别,将IO分成了缓存IO(Buffered IO)和直接IO(Direct io)。其实就是名词上的不同而已。这里面的buffer的含义跟内存中buffer cache有概念上的不同。实际上这里Buffered IO的含义,相当于内存中的buffer cache+page cache,就是IO经过缓存的意思。到这我们思考一个问题,如果cgroup针对IO的资源限制实现在了通用块设备层,那么将会对哪些IO操作有影响呢?其实原则上说都有影响,因为绝大多数数据都是要经过通用块设备层写入存储的,但是对于应用程序来说感受可能不一样。在一般IO的情况下,应用程序很可能很快的就写完了数据(在数据量小于缓存空间的情况下),然后去做其他事情了。这时应用程序感受不到自己被限速了,而内核在处理write-back的阶段,由于没有相关page cache中的inode是属于那个cgroup的信息记录,所以所有的page cache的回写只能放到cgroup的root组中进行限制,而不能在其他cgroup中进行限制,因为root组的cgroup一般是不做限制的,所以就相当于目前的cgroup的blkio对buffered IO是没有限速支持的。这个功能将在使用了unified-hierarchy体系的cgroup v2中的部分文件系统(ext系列)已经得到得到支持,目前这个功能还在开发中,据说将在4.5版本的内核中正式发布。

而在Sync IO和Direct IO的情况下,由于应用程序写的数据是不经过缓存层的,所以能直接感受到速度被限制,一定要等到整个数据按限制好的速度写完或者读完,才能返回。这就是当前cgroup的blkio限制所能起作用的环境限制。了解了这个之后,我们就可以来看:

##blkio配置方法

###权重比例分配

我们这次直接使用命令行的方式对cgroup进行操作。在我们的系统上,我们现在想创建两个cgroup组,一个叫test1,一个叫test2。我们想让这两个组的进程在对/dev/sdb,设备号为8:16的这个磁盘进行读写的时候按权重比例进行io资源的分配。具体配置方法如下:

首先确认系统上已经mount了相关的cgroup目录:

[root@zorrozou-pc0 ~]# ls /sys/fs/cgroup/blkio/
blkio.io_merged blkio.io_service_bytes_recursive blkio.io_wait_time blkio.sectors blkio.throttle.read_iops_device blkio.weight tasks
blkio.io_merged_recursive blkio.io_serviced blkio.io_wait_time_recursive blkio.sectors_recursive blkio.throttle.write_bps_device blkio.weight_device
blkio.io_queued blkio.io_serviced_recursive blkio.leaf_weight blkio.throttle.io_service_bytes blkio.throttle.write_iops_device cgroup.clone_children
blkio.io_queued_recursive blkio.io_service_time blkio.leaf_weight_device blkio.throttle.io_serviced blkio.time cgroup.procs
blkio.io_service_bytes blkio.io_service_time_recursive blkio.reset_stats blkio.throttle.read_bps_device blkio.time_recursive notify_on_release

然后创建两个针对blkio的cgroup

[root@zorrozou-pc0 ~]# mkdir /sys/fs/cgroup/blkio/test1
[root@zorrozou-pc0 ~]# mkdir /sys/fs/cgroup/blkio/test2

相关目录下会自动产生相关配置项:

[root@zorrozou-pc0 ~]# ls /sys/fs/cgroup/blkio/test{1,2}
/sys/fs/cgroup/blkio/test1:
blkio.io_merged blkio.io_service_bytes_recursive blkio.io_wait_time blkio.sectors blkio.throttle.read_iops_device blkio.weight tasks
blkio.io_merged_recursive blkio.io_serviced blkio.io_wait_time_recursive blkio.sectors_recursive blkio.throttle.write_bps_device blkio.weight_device
blkio.io_queued blkio.io_serviced_recursive blkio.leaf_weight blkio.throttle.io_service_bytes blkio.throttle.write_iops_device cgroup.clone_children
blkio.io_queued_recursive blkio.io_service_time blkio.leaf_weight_device blkio.throttle.io_serviced blkio.time cgroup.procs
blkio.io_service_bytes blkio.io_service_time_recursive blkio.reset_stats blkio.throttle.read_bps_device blkio.time_recursive notify_on_release

/sys/fs/cgroup/blkio/test2:
blkio.io_merged blkio.io_service_bytes_recursive blkio.io_wait_time blkio.sectors blkio.throttle.read_iops_device blkio.weight tasks
blkio.io_merged_recursive blkio.io_serviced blkio.io_wait_time_recursive blkio.sectors_recursive blkio.throttle.write_bps_device blkio.weight_device
blkio.io_queued blkio.io_serviced_recursive blkio.leaf_weight blkio.throttle.io_service_bytes blkio.throttle.write_iops_device cgroup.clone_children
blkio.io_queued_recursive blkio.io_service_time blkio.leaf_weight_device blkio.throttle.io_serviced blkio.time cgroup.procs
blkio.io_service_bytes blkio.io_service_time_recursive blkio.reset_stats blkio.throttle.read_bps_device blkio.time_recursive notify_on_release

之后我们就可以进行限制了。针对cgroup进行权重限制的配置有blkio.weight,是单纯针对cgroup进行权重配置的,还有blkio.weight_device可以针对设备单独进行限制,我们都来试试。首先我们想设置test1和test2使用任何设备的io权重比例都是1:2:

[root@zorrozou-pc0 zorro]# echo 100 > /sys/fs/cgroup/blkio/test1/blkio.weight
[root@zorrozou-pc0 zorro]# echo 200 > /sys/fs/cgroup/blkio/test2/blkio.weight

注意权重设置的取值范围为:10-1000。然后我们来写一个测试脚本:

#!/bin/bash

testfile1=/home/test1
testfile2=/home/test2

if [ -e $testfile1 ]
then
rm -rf $testfile1
fi

if [ -e $testfile2 ]
then
rm -rf $testfile2
fi

sync
echo 3 > /proc/sys/vm/drop_caches

cgexec -g blkio:test1 dd if=/dev/zero of=$testfile1 oflag=direct bs=1M count=1023 &

cgexec -g blkio:test2 dd if=/dev/zero of=$testfile2 oflag=direct bs=1M count=1023 &

我们dd的时候使用的是direct标记,在这使用sync和不加任何标记的话都达不到效果。因为权重限制是基于cfq实现,cfq要标记进程,而buffered IO都是内核同步,无法标记进程。使用iotop查看限制效果:

[root@zorrozou-pc0 zorro]# iotop -b -n1|grep direct
1519 be/4 root 0.00 B/s 110.00 M/s 0.00 % 99.99 % dd if=/dev/zero of=/home/test2 oflag=direct bs=1M count=1023
1518 be/4 root 0.00 B/s 55.00 M/s 0.00 % 99.99 % dd if=/dev/zero of=/home/test1 oflag=direct bs=1M count=1023

却是达到了1:2比例限速的效果。此时对于磁盘读取的限制效果也一样,具体测试用例大家可以自己编写。读取的时候要注意,仍然要保证读取的文件不在page cache中,方法就是:echo 3 > /proc/sys/vm/drop_caches。因为在page cache中的数据已经在内存里了,直接修改是直接改内存中的内容,只有write-back的时候才会经过cfq。

我们再来试一下针对设备的权重分配,请注意设备号的填写格式:

[root@zorrozou-pc0 zorro]# echo “8:16 400” > /sys/fs/cgroup/blkio/test1/blkio.weight_device
[root@zorrozou-pc0 zorro]# echo “8:16 200” > /sys/fs/cgroup/blkio/test2/blkio.weight_device

[root@zorrozou-pc0 zorro]# iotop -b -n1|grep direct
1800 be/4 root 0.00 B/s 102.24 M/s 0.00 % 99.99 % dd if=/dev/zero of=/home/test1 oflag=direct bs=1M count=1023
1801 be/4 root 0.00 B/s 51.12 M/s 0.00 % 99.99 % dd if=/dev/zero of=/home/test2 oflag=direct bs=1M count=1023

我们会发现,这时权重确实是按照最后一次的设置,test1和test2变成了2:1的比例,而不是1:2了。这里要说明的就是,注意blkio.weight_device的设置会覆盖blkio.weight的设置,因为前者的设置精确到了设备,Linux在这里的策略是,越精确越优先。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

###读写带宽和iops限制

针对读写带宽和iops的限制都是绝对值限制,所以我们不用两个cgroup做对比了。我们就设置test1的写带宽速度为1M/s:

[root@zorrozou-pc0 zorro]# echo “8:16 1048576” > /sys/fs/cgroup/blkio/test1/blkio.throttle.write_bps_device

[root@zorrozou-pc0 zorro]# sync
[root@zorrozou-pc0 zorro]# echo 3 > /proc/sys/vm/drop_caches

[root@zorrozou-pc0 zorro]# cgexec -g blkio:test1 dd if=/dev/zero of=/home/test oflag=direct count=1024 bs=1M
^C21+0 records in
21+0 records out
22020096 bytes (22 MB) copied, 21.012 s, 1.0 MB/s

此时不用dd命令执行完,稍等一下中断执行就能看到速度确实限制在了1M/s。写的同时,iostat显示为:

[zorro@zorrozou-pc0 ~]$ iostat -x 1
Linux 4.3.3-2-ARCH (zorrozou-pc0.tencent.com) 2016年01月15日 x86_64 (4 CPU)

avg-cpu: %user %nice %system %iowait %steal %idle
0.50 0.00 0.50 25.13 0.00 73.87

Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
sda 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sdb 0.00 0.00 0.00 1.00 0.00 1024.00 2048.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-0 0.00 0.00 0.00 1.00 0.00 1024.00 2048.00 1.00 1000.00 0.00 1000.00 1000.00 100.00
dm-1 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-2 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-3 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00

avg-cpu: %user %nice %system %iowait %steal %idle
1.25 0.00 0.50 24.81 0.00 73.43

Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
sda 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sdb 0.00 5.00 0.00 6.00 0.00 1060.00 353.33 0.06 9.33 0.00 9.33 9.50 5.70
dm-0 0.00 0.00 0.00 10.00 0.00 1060.00 212.00 1.08 109.00 0.00 109.00 100.00 100.00
dm-1 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-2 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-3 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00

avg-cpu: %user %nice %system %iowait %steal %idle
1.25 0.00 1.00 24.44 0.00 73.32

Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
sda 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sdb 0.00 0.00 0.00 1.00 0.00 1024.00 2048.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-0 0.00 0.00 0.00 1.00 0.00 1024.00 2048.00 1.00 993.00 0.00 993.00 1000.00 100.00
dm-1 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-2 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-3 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00

avg-cpu: %user %nice %system %iowait %steal %idle
1.50 0.25 0.75 24.50 0.00 73.00

Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
sda 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sdb 0.00 0.00 0.00 1.00 0.00 1024.00 2048.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-0 0.00 0.00 0.00 1.00 0.00 1024.00 2048.00 1.00 1000.00 0.00 1000.00 1000.00 100.00
dm-1 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-2 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-3 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00

可以看到写的速度确实为1024wkB/s左右。我们再来试试读,先创建一个大文件,此处没有限速:

[root@zorrozou-pc0 zorro]# dd if=/dev/zero of=/home/test oflag=direct count=1024 bs=1M
1024+0 records in
1024+0 records out
1073741824 bytes (1.1 GB) copied, 10.213 s, 105 MB/s

然后进行限速设置并确认:

[root@zorrozou-pc0 zorro]# sync
[root@zorrozou-pc0 zorro]# echo 3 > /proc/sys/vm/drop_caches
[root@zorrozou-pc0 zorro]# echo “8:16 1048576” > /sys/fs/cgroup/blkio/test1/blkio.throttle.read_bps_device
[root@zorrozou-pc0 zorro]# cgexec -g blkio:test1 dd if=/home/test of=/dev/null iflag=direct count=1024 bs=1M
^C15+0 records in
14+0 records out
14680064 bytes (15 MB) copied, 15.0032 s, 978 kB/s

iostat结果:

avg-cpu: %user %nice %system %iowait %steal %idle
0.75 0.00 0.75 24.63 0.00 73.88

Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
sda 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sdb 0.00 0.00 2.00 0.00 1024.00 0.00 1024.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-0 0.00 0.00 2.00 0.00 1024.00 0.00 1024.00 1.65 825.00 825.00 0.00 500.00 100.00
dm-1 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-2 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-3 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00

avg-cpu: %user %nice %system %iowait %steal %idle
0.75 0.00 0.50 24.87 0.00 73.87

Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
sda 0.00 2.00 0.00 2.00 0.00 16.00 16.00 0.02 10.00 0.00 10.00 10.00 2.00
sdb 0.00 0.00 2.00 0.00 1024.00 0.00 1024.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-0 0.00 0.00 2.00 0.00 1024.00 0.00 1024.00 1.65 825.00 825.00 0.00 500.00 100.00
dm-1 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-2 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-3 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00

最后是iops的限制,我就不废话了,直接上命令执行结果:

[root@zorrozou-pc0 zorro]# echo “8:16 20” > /sys/fs/cgroup/blkio/test1/blkio.throttle.write_iops_device
[root@zorrozou-pc0 zorro]# rm /home/test
[root@zorrozou-pc0 zorro]# sync
[root@zorrozou-pc0 zorro]# echo 3 > /proc/sys/vm/drop_caches
[root@zorrozou-pc0 zorro]# cgexec -g blkio:test1 dd if=/dev/zero of=/home/test oflag=direct count=1024 bs=1M
^C121+0 records in
121+0 records out
126877696 bytes (127 MB) copied, 12.0576 s, 10.5 MB/s

[zorro@zorrozou-pc0 ~]$ iostat -x 1
avg-cpu: %user %nice %system %iowait %steal %idle
0.50 0.00 0.25 24.81 0.00 74.44

Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
sda 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sdb 0.00 0.00 0.00 20.00 0.00 10240.00 1024.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-0 0.00 0.00 0.00 20.00 0.00 10240.00 1024.00 2.00 100.00 0.00 100.00 50.00 100.00
dm-1 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-2 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-3 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00

avg-cpu: %user %nice %system %iowait %steal %idle
0.75 0.00 0.25 24.31 0.00 74.69

Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
sda 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
sdb 0.00 0.00 0.00 20.00 0.00 10240.00 1024.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-0 0.00 0.00 0.00 20.00 0.00 10240.00 1024.00 2.00 100.00 0.00 100.00 50.00 100.00
dm-1 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-2 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
dm-3 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00

iops的读限制我就不在废话了,大家可以自己做实验测试一下。

##其他相关文件

###针对权重比例限制的相关文件

blkio.leaf_weight[_device]

其意义等同于blkio.weight[_device],主要表示当本cgroup中有子cgroup的时候,本cgroup的进程和子cgroup中的进程所分配的资源比例是怎么样的。举个例子说吧,假设有一组cgroups的关系是这样的:

root
/ | \
A B leaf
400 200 200

leaf就表示root组下的进程所占io资源的比例。
此时A组中的进程可以占用的比例为:400/(400+200+200) * 100% = 50%
B为:200/(400+200+200) * 100% = 25%
而root下的进程为:200/(400+200+200) * 100% = 25%

blkio.time

统计相关设备的分配给本组的io处理时间,单位为ms。权重就是依据此时间比例进行分配的。

blkio.sectors

统计本cgroup对设备的读写扇区个数。

blkio.io_service_bytes

统计本cgroup对设备的读写字节个数。

blkio.io_serviced

统计本cgroup对设备的读写操作个数。

blkio.io_service_time

统计本cgroup对设备的各种操作时间。时间单位是ns。

blkio.io_wait_time

统计本cgroup对设备的各种操作的等待时间。时间单位是ns。

blkio.io_merged

统计本cgroup对设备的各种操作的合并处理次数。

blkio.io_queued

统计本cgroup对设备的各种操作的当前正在排队的请求个数。

blkio.*_recursive

这一堆文件是相对应的不带_recursive的文件的递归显示版本,所谓递归的意思就是,它会显示出包括本cgroup在内的衍生cgroup的所有信息的总和。

###针对带宽和iops限制的相关文件

blkio.throttle.io_serviced

统计本cgroup对设备的读写操作个数。

blkio.throttle.io_service_bytes

统计本cgroup对设备的读写字节个数。

blkio.reset_stats

对本文件写入一个int可以对以上所有文件的值置零,重新开始累计。

##最后

其实一直纠结要不要写这部分IO隔离的文档,因为看上去意义不大。一则因为目前IO隔离似乎工作场景里用的不多,二则因为目前内核中这部分代码还在进行较大变化的调整,还要继续加入其它功能。从内核Linux 3.16版本之后,cgroup调整方向,开始了基于unified hierarchy架构的cgroup v2。IO部分在write-back部分进行了较大调整,加入了对buffered IO的资源限制。我们这次系统环境为ArchLinux,内核版本为Linux 4.3.3,虽然环境中的unified hierarchy的开发版本功能已经部分支持了,但是思考再三还是暂时不加入到此文档中。新架构的cgoup v2预计会跟随Linux 4.5一起推出,到时候我们再做详细分析好了。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

附送一张更详细的Linux 4.0 IO协议栈框架图

Linux IO协议栈框架图

Cgroup – Linux内存资源管理

#Cgroup – Linux内存资源管理
Zorro] icon

Hi,我是Zorro。这是我的微博地址,我会不定期在这里更新文章,如果你有兴趣,可以来关注我呦。

另外,我的其他联系方式:

Email: <mini.jerry@gmail.com>

QQ: 30007147

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

在聊cgroup的内存限制之前,我们有必要先来讲解一下:

##Linux内存管理基础知识

###free命令

无论从任何角度看,Linux的内存管理都是一坨麻烦的事情,当然我们也可以用一堆、一片、一块、一筐来形容这个事情,但是毫无疑问,用一坨来形容它简直恰当无比。在理解它之前,我甚至不会相信精妙的和恶心可以同时形容同一件事情,是的,在我看来它就是这样的。其实我只是做个铺垫,让大家明白,我们下面要讲的内容,绝不是一个成体系的知识,所以,学习起来也确实很麻烦。甚至,我写这个技术文章之前一度考虑了很久该怎么写?从哪里开始写?思考了半天,还是不能免俗,我们无奈,仍然先从free命令说起:

[root@zorrozou-pc ~]# free
total used free shared buffers cached
Mem: 131904480 6681612 125222868 0 478428 4965180
-/+ buffers/cache: 1238004 130666476
Swap: 2088956 0 2088956

这个命令几乎是每一个使用过Linux的人必会的命令,但越是这样的命令,似乎真正明白的人越少(我是说比例越少)。一般情况下,对此命令的理解可以分这几个阶段:

  1. 我擦,内存用了好多,6个多G,可是我什么都没有运行啊?为什么会这样?Linux好占内存。
  2. 嗯,根据我专业的眼光看出来,内存才用了1G多点,还有很多剩余内存可用。buffers/cache占用的较多,说明系统中有进程曾经读写过文件,但是不要紧,这部分内存是当空闲来用的。
  3. free显示的是这样,好吧我知道了。神马?你问我这些内存够不够,我当然不知道啦!我特么怎么知道你程序怎么写的?

如果你的认识在第一种阶段,那么请你继续补充关于Linux的buffers/cache的知识。如果你处在第二阶段,好吧,你已经是个老手了,但是需要提醒的是,上帝给你关上一扇门的同时,肯定都会给你放一条狗的。是的,Linux的策略是:内存是用来用的,而不是用来看的。但是,只要是用了,就不是没有成本的。有什么成本,凭你对buffer/cache的理解,应该可以想的出来。一般我比较认同第三种情况,一般光凭一个free命令的显示,是无法判断出任何有价值的信息的,我们需要结合业务的场景以及其他输出综合判断目前遇到的问题。当然也可能这种人给人的第一感觉是他很外行,或者他真的是外行。

无论如何,free命令确实给我门透露了一些有用的信息,比如内存总量,剩余多少,多少用在了buffers/cache上,Swap用了多少,如果你用了其它参数还能看到一些其它内容,这里不做一一列举。那么这里又引申出另一些概念,什么是buffer?什么是cache?什么是swap?由此我们就直接引出另一个命令:

[root@zorrozou-pc ~]# cat /proc/meminfo
MemTotal: 131904480 kB
MemFree: 125226660 kB
Buffers: 478504 kB
Cached: 4966796 kB
SwapCached: 0 kB
Active: 1774428 kB
Inactive: 3770380 kB
Active(anon): 116500 kB
Inactive(anon): 3404 kB
Active(file): 1657928 kB
Inactive(file): 3766976 kB
Unevictable: 0 kB
Mlocked: 0 kB
SwapTotal: 2088956 kB
SwapFree: 2088956 kB
Dirty: 336 kB
Writeback: 0 kB
AnonPages: 99504 kB
Mapped: 20760 kB
Shmem: 20604 kB
Slab: 301292 kB
SReclaimable: 229852 kB
SUnreclaim: 71440 kB
KernelStack: 3272 kB
PageTables: 3320 kB
NFS_Unstable: 0 kB
Bounce: 0 kB
WritebackTmp: 0 kB
CommitLimit: 68041196 kB
Committed_AS: 352412 kB
VmallocTotal: 34359738367 kB
VmallocUsed: 493196 kB
VmallocChunk: 34291062284 kB
HardwareCorrupted: 0 kB
AnonHugePages: 49152 kB
HugePages_Total: 0
HugePages_Free: 0
HugePages_Rsvd: 0
HugePages_Surp: 0
Hugepagesize: 2048 kB
DirectMap4k: 194816 kB
DirectMap2M: 3872768 kB
DirectMap1G: 132120576 kB

以上显示的内容都是些什么鬼?

其实这个问题的答案也是另一个问题的答案,即:Linux是如何使用内存的?了解清楚这个问题是很有必要的,因为只有先知道了Linux如何使用内存,我们在能知道内存可以如何限制,以及,做了限制之后会有什么问题?我们在此先例举出几个常用概念的意义:

内存,作为一种相对比较有限的资源,内核在考虑其管理时,无非应该主要从以下出发点考虑:

  1. 内存够用时怎么办?
  2. 内存不够用时怎么办?

在内存够用时,内核的思路是,如何尽量提高资源的利用效率,以加快系统整体响应速度和吞吐量?于是内存作为一个CPU和I/O之间的大buffer的功能就呼之欲出了。为此,内核设计了以下系统来做这个功能:
###Buffers/Cached
buffer和cache是两个在计算机技术中被用滥的名词,放在不通语境下会有不同的意义。在内存管理中,我们需要特别澄清一下,这里的buffer指Linux内存的:Buffer cache。这里的cache指Linux内存中的:Page cache。翻译成中文可以叫做缓冲区缓存和页面缓存。在历史上,它们一个(buffer)被用来当成对io设备写的缓存,而另一个(cache)被用来当作对io设备的读缓存,这里的io设备,主要指的是块设备文件和文件系统上的普通文件。但是现在,它们的意义已经不一样了。在当前的内核中,page cache顾名思义就是针对内存页的缓存,说白了就是,如果有内存是以page进行分配管理的,都可以使用page cache作为其缓存来使用。当然,不是所有的内存都是以页(page)进行管理的,也有很多是针对块(block)进行管理的,这部分内存使用如果要用到cache功能,则都集中到buffer cache中来使用。(从这个角度出发,是不是buffer cache改名叫做block cache更好?)然而,也不是所有块(block)都有固定长度,系统上块的长度主要是根据所使用的块设备决定的,而页长度在X86上无论是32位还是64位都是4k。

而明白了这两套缓存系统的区别,也就基本可以理解它们究竟都可以用来做什么了。

什么是page cache

Page cache主要用来作为文件系统上的文件数据的缓存来用,尤其是针对当进程对文件有read/write操作的时候。如果你仔细想想的话,作为可以映射文件到内存的系统调用:mmap是不是很自然的也应该用到page cache?如果你再仔细想想的话,malloc会不会用到page cache?

以上提出的问题都请自己思考,本文档不会给出标准答案。

在当前的实现里,page cache也被作为其它文件类型的缓存设备来用,所以事实上page cache也负责了大部分的块设备文件的缓存工作。

什么是buffer cache

Buffer cache则主要是设计用来在系统对块设备进行读写的时候,对块进行数据缓存的系统来使用。但是由于page cache也负责块设备文件读写的缓存工作,于是,当前的buffer cache实际上要负责的工作比较少。这意味着某些对块的操作会使用buffer cache进行缓存,比如我们在格式化文件系统的时候。

一般情况下两个缓存系统是一起配合使用的,比如当我们对一个文件进行写操作的时候,page cache的内容会被改变,而buffer cache则可以用来将page标记为不同的缓冲区,并记录是哪一个缓冲区被修改了。这样,内核在后续执行脏数据的回写(writeback)时,就不用将整个page写回,而只需要写回修改的部分即可。

有搞大型系统经验的人都知道,缓存就像万金油,只要哪里有速度差异产生的瓶颈,就可以在哪里抹。但是其成本之一就是,需要维护数据的一致性。内存缓存也不例外,内核需要维持其一致性,在脏数据产生较快或数据量较大的时候,缓存系统整体的效率一样会下降,因为毕竟脏数据写回也是要消耗IO的。这个现象也会表现在这样一种情况下,就是当你发现free的时候,内存使用量较大,但是去掉了buffer/cache的使用之后剩余确很多。以一般的理解,都会认为此时进程如果申请内存,内核会将buffer/cache占用的内存当成空闲的内存分给进程,这是没错的。但是其成本是,在分配这部分已经被buffer/cache占用的内存的时候,内核会先对其上面的脏数据进行写回操作,保证数据一致后才会清空并分给进程使用。如果此时你的进程是突然申请大量内存,而且你的业务是一直在产生很多脏数据(比如日志),并且系统没有及时写回的时候,此时系统给进程分配内存的效率会很慢,系统IO也会很高。那么此时你还以为buffer/cache可以当空闲内存使用么?

思考题:Linux什么时候会将脏数据写回到外部设备上?这个过程如何进行人为干预?

这足可以证明一点,以内存管理的复杂度,我们必须结合系统上的应用状态来评估系统监控命令所给出的数据,才是做评估的正确途径。如果你不这样做,那么你就可以轻而易举的得出“Linux系统好烂啊!“这样的结论。也许此时,其实是你在这个系统上跑的应用很烂的缘故导致的问题。

接下来,当内存不够用的时候怎么办?

我们好像已经分析了一种内存不够用的状态,就是上述的大量buffer/cache把内存几乎占满的情况。但是基于Linux对内存的使用原则,这不算是不够用,但是这种状态导致IO变高了。我们进一步思考,假设系统已经清理了足够多的buffer/cache分给了内存,而进程还在嚷嚷着要内存咋办?

此时内核就要启动一系列手段来让进程尽量在此时能够正常的运行下去。

请注意我在这说的是一种异常状态!我之所以要这样强调是因为,很多人把内存用满了当称一种正常状态。他们认为,当我的业务进程在内存使用到压力边界的情况下,系统仍然需要保证让业务进程有正常的状态!这种想法显然是缘木求鱼了。另外我还要强调一点,系统提供的是内存管理的机制和手段,而内存用的好不好,主要是业务进程的事情,责任不能本末倒置。

###谁该SWAP?

首先是Swap机制。Swap是交换技术,这种技术是指,当内存不够用的时候,我们可以选择性的将一块磁盘、分区或者一个文件当成交换空间,将内存上一些临时用不到的数据放到交换空间上,以释放内存资源给急用的进程。

哪些数据可能会被交换出去呢?从概念上判断,如果一段内存中的数据被经常访问,那么就不应该被交换到外部设备上,因为这样的数据如果交换出去的话会导致系统响应速度严重下降。内存管理需要将内存区分为活跃的(Active)和不活跃的(Inactive),再加上一个进程使用的用户空间内存映射包括文件影射(file)和匿名影射(anon),所以就包括了Active(anon)、Inactive(anon)、Active(file)和Inactive(file)。你说神马?啥是文件影射(file)和匿名影射(anon)?好吧,我们可以这样简单的理解,匿名影射主要是诸如进程使用malloc和mmap的MAP_ANONYMOUS的方式申请的内存,而文件影射就是使用mmap影射的文件系统上的文件,这种文件系统上的文件既包括普通的文件,也包括临时文件系统(tmpfs)。这意味着,Sys V的IPC和POSIX的IPC(IPC是进程间通信机制,在这里主要指共享内存,信号量数组和消息队列)都是通过文件影射方式体现在用户空间内存中的。这两种影射的内存都会被算成进程的RSS,但是也一样会被显示在cache的内存计数中,在相关cgroup的另一项统计中,共享内存的使用和文件缓存(file cache)也都会被算成是cgroup中的cache使用的总量。这个统计显示的方法是:

[root@zorrozou-pc ~]# cat /cgroup/memory/memory.stat
cache 94429184
rss 102973440
rss_huge 50331648
mapped_file 21512192
swap 0
pgpgin 656572990
pgpgout 663474908
pgfault 2871515381
pgmajfault 1187
inactive_anon 3497984
active_anon 120524800
inactive_file 39059456
active_file 34484224
unevictable 0
hierarchical_memory_limit 9223372036854775807
hierarchical_memsw_limit 9223372036854775807
total_cache 94429184
total_rss 102969344
total_rss_huge 50331648
total_mapped_file 21520384
total_swap 0
total_pgpgin 656572990
total_pgpgout 663474908
total_pgfault 2871515388
total_pgmajfault 1187
total_inactive_anon 3497984
total_active_anon 120524800
total_inactive_file 39059456
total_active_file 34484224
total_unevictable 0

好吧,说了这么半天终于联系到一个cgroup的内存限制相关的文件了。在这需要说明的是,你之所以看见我废话这么多,是因为我们必须先基本理清楚Linux系统的内存管理方式,才能进一步对cgroup中的内存限制做规划使用,否则同样的名词会有很多的歧义。就比如我们在观察某一个cgroup中的cache占用数据的时候,我们究竟该怎么理解它?真的把它当成空闲空间来看么?

我们撤的有点远,回过头来说说这些跟Swap有什么关系?还是刚才的问题,什么内容该被从内存中交换出去呢?文件cache是一定不需要的,因为既然是cache,就意味着它本身就是硬盘上的文件(当然你现在应该知道了,它也不仅仅只有文件),那么如果是硬盘上的文件,就不用swap交换出去,只要写回脏数据,保持数据一致之后清除就可以了,这就是刚才说过的缓存清楚机制。但是我们同时也要知道,并不是所有被标记为cache的空间都能被写回硬盘的(是的,比如共享内存)。那么能交换出去内存应该主要包括有Inactive(anon)这部分内存。主要注意的是,内核也将共享内存作为计数统计近了Inactive(anon)中去了(是的,共享内存也可以被Swap)。还要补充一点,如果内存被mlock标记加锁了,则也不会交换,这是对内存加mlock锁的唯一作用。刚才我们讨论的这些计数,很可能会随着Linux内核的版本改变而产生变化,但是在比较长的一段时间内,我们可以这样理解。

我们基本搞清了swap这个机制的作用效果,那么既然swap是内部设备和外部设备的数据拷贝,那么加一个缓存就显得很有必要,这个缓存就是swapcache,在memory.stat文件中,swapcache是跟anon page被一起记录到rss中的,但是并不包含共享内存。另外再说明一下,HugePages也是不会交换的。显然,当前的swap空间用了多少,总共多少,这些我们也可以在相关的数据中找到答案。

以上概念中还有一些名词大家可能并不清楚其含义,比如RSS或HugePages。请自行查资料补上这些知识。为了让大家真的理解什么是RSS,请思考ps aux命令中显示的VSZ,RSS和cat /proc/pid/smaps中显示的:PSS这三个进程占用内存指标的差别?

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

###何时SWAP?
搞清楚了谁该swap,那么还要知道什么时候该swap。这看起来比较简单,内存耗尽而且cache也没什么可以回收的时候就应该触发swap。其实现实情况也没这么简单,实际上系统在内存压力可能不大的情况下也会swap,这种情况并不是我们今天要讨论的范围。

思考题:除了内存被耗尽的时候要swap,还有什么时候会swap?如何调整内核swap的行为?如何查看当前系统的swap空间有哪些?都是什么类型?什么是swap权重?swap权重有什么意义?

其实绝大多数场景下,什么时候swap并不重要,而swap之后的事情相对却更重要。大多数的内存不够用,只是临时不够用,比如并发突增等突发情况,这种情况的特点是时间持续短,此时swap机制作为一种临时的中转措施,可以起到对业务进程的保护作用。因为如果没有swap,内存耗尽的结果一般都是触发oom killer,会杀掉此时积分比较高的进程。如果更严重的话,内存不够用还会触发进程D状态死锁,这一般发生在多个进程同时要申请内存的时候,此时oom killer机制也可能会失效,因为需要被干掉的积分比较高的进程很可能就是需要申请内存的进程,而这个进程本身因为正在争抢内存而导致陷入D状态,那么此时kill就可能是对它无效的。

但是swap也不是任何时候都有很好的保护效果。如果内存申请是长期并大量的,那么交换出去的数据就会因为长时间驻留在外部设备上,导致进程调用这段内存的几率大大增加,当进程很频繁的使用它已经被交换出去的内存时,就会让整个系统处在io繁忙的状态,此时进程的响应速度会严重下降,导致整个系统夯死。对于系统管理员来说,这种情况是完全不能接受的,因为故障之后的第一要务是赶紧恢复服务,但是swap频繁使用的IO繁忙状态会导致系统除了断电重启之外,没有其它可靠手段可以让系统从这种状态中恢复回来,所以这种情况是要尽力避免的。此时,如果有必要,我们甚至可以考虑不用swap,哪怕内存过量使用被oom,或者进程D状态都是比swap导致系统卡死的情况更好处理的状态。如果你的环境需求是这样的,那么可以考虑关闭swap。

###进程申请内存的时候究竟会发生什么?

刚才我们从系统宏观的角度简要说明了一下什么是buffer/cache以及swap。下面我们从一个更加微观的角度来把一个内存申请的过程以及相关机制什么时候触发给串联起来。本文描述的过程是基于Linux 3.10内核版本的,Linux 4.1基本过程变化不大。如果你想确认在你的系统上究竟是什么样子,请自行翻阅相关内核代码。

进程申请内存可能用到很多种方法,最常见的就是malloc和mmap。但是这对于我们并不重要,因为无论是malloc还是mmap,或是其他的申请内存的方法,都不会真正的让内核去给进程分配一个实际的物理内存空间。真正会触发分配物理内存的行为是缺页异常

缺页异常就是我们可以在memory.stat中看到的total_pgfault,这种异常一般分两种,一种叫major fault,另一种叫minor fault。这两种异常的主要区别是,进程所请求的内存数据是否会引发磁盘io?如果会引发,就是一个majfault,如果不引发,那就是minfault。就是说如果产生了major fault,这个数据基本上就意味着已经被交换到了swap空间上。

缺页异常的处理过程大概可以整理为以下几个路径:

首先检查要访问的虚拟地址是否合法,如果合法则继续查找和分配一个物理页,步骤如下:

  1. 检查发生异常的虚拟地址是不是在物理页表中不存在?如果是,并且是匿名影射,则申请置0的匿名影射内存,此时也有可能是影射了某种虚拟文件系统,比如共享内存,那么就去影射相关的内存区,或者发生COW写时复制申请新内存。如果是文件影射,则有两种可能,一种是这个影射区是一个page cache,直接将相关page cache区影射过来即可,或者COW新内存存放需要影射的文件内容。如果page cache中不存在,则说明这个区域已经被交换到swap空间上,应该去处理swap。
  2. 如果页表中已经存在需要影射的内存,则检查是否要对内存进行写操作,如果不写,那就直接复用,如果要写,就发生COW写时复制,此时的COW跟上面的处理过程不完全相同,在内核中,这里主要是通过do_wp_page方法实现的。

如果需要申请新内存,则都会通过alloc_page_vma申请新内存,而这个函数的核心方法是__alloc_pages_nodemask,也就是Linux内核著名的内存管理系统**伙伴系统**的实现。

分配过程先会检查空闲页表中有没有页可以申请,实现方法是:get_page_from_freelist,我们并不关心正常情况,分到了当然一切ok。更重要的是异常处理,如果空闲中没有,则会进入__alloc_pages_slowpath方法进行处理。这个处理过程的主逻辑大概这样:

  1. 唤醒kswapd进程,把能换出的内存换出,让系统有内存可用。
  2. 继续检查看看空闲中是否有内存。有了就ok,没有继续下一步:
  3. 尝试清理page cache,清理的时候会将进程置为D状态。如果还申请不到内存则:
  4. 启动oom killer干掉一些进程释放内存,如果这样还不行则:
  5. 回到步骤1再来一次!

当然以上逻辑要符合一些条件,但是这一般都是系统默认的状态,比如,你必须启用oom killer机制等。另外这个逻辑中有很多其它状态与本文无关,比如检查内存水印、检查是否是高优先级内存申请等等,当然还有关于numa节点状态的判断处理,我没有一一列出。另外,以上逻辑中,不仅仅只有清理cache的时候会使进程进入D状态,还有其它逻辑也会这样做。这就是为什么在内存不够用的情况下,oom killer有时也不生效,因为可能要干掉的进程正好陷入这个逻辑中的D状态了。

以上就是内存申请中,大概会发生什么的过程。当然,我们这次主要是真对本文的重点cgroup内存限制进行说明,当我们处理限制的时候,更多需要关心的是当内存超限了会发生什么?对边界条件的处理才是我们这次的主题,所以我并没有对正常申请到的情况做细节说明,也没有对用户态使用malloc什么时候使用sbrk还是mmap来申请内存做出细节说明,毕竟那是程序正常状态的时候的事情,后续可以另写一个内存优化的文章主要讲解那部分。

下面我们该进入正题了:

##Cgroup内存限制的配置

当限制内存时,我们最好先想清楚如果内存超限了会发生什么?该怎么处理?业务是否可以接受这样的状态?这就是为什么我们在讲如何限制之前说了这么多基础知识的“废话”。其实最简单的莫过于如何进行限制了,我们的系统环境还是沿用上一次讲解CPU内存隔离的环境,使用cgconfig和cgred服务进行cgroup的配置管理。还是创建一个zorro用户,对这个用户产生的进程进行内存限制。基础配置方法不再多说,如果不知道的请参考这个文档

环境配置好之后,我们就可以来检查相关文件了。内存限制的相关目录根据cgconfig.config的配置放在了/cgroup/memory目录中,如果你跟我做了一样的配置,那么这个目录下的内容应该是这样的:

[root@zorrozou-pc ~]# ls /cgroup/memory/
cgroup.clone_children memory.failcnt memory.kmem.slabinfo memory.kmem.usage_in_bytes memory.memsw.limit_in_bytes memory.oom_control memory.usage_in_bytes shrek
cgroup.event_control memory.force_empty memory.kmem.tcp.failcnt memory.limit_in_bytes memory.memsw.max_usage_in_bytes memory.pressure_level memory.use_hierarchy tasks
cgroup.procs memory.kmem.failcnt memory.kmem.tcp.limit_in_bytes memory.max_usage_in_bytes memory.memsw.usage_in_bytes memory.soft_limit_in_bytes zorro
cgroup.sane_behavior memory.kmem.limit_in_bytes memory.kmem.tcp.max_usage_in_bytes memory.meminfo memory.move_charge_at_immigrate memory.stat notify_on_release
jerry memory.kmem.max_usage_in_bytes memory.kmem.tcp.usage_in_bytes memory.memsw.failcnt memory.numa_stat memory.swappiness release_agent

其中,zorro、jerry、shrek都是目录概念跟cpu隔离的目录树结构类似。相关配置文件内容:

[root@zorrozou-pc ~]# cat /etc/cgconfig.conf mount {
cpu = /cgroup/cpu;
cpuset = /cgroup/cpuset;
cpuacct = /cgroup/cpuacct;
memory = /cgroup/memory;
devices = /cgroup/devices;
freezer = /cgroup/freezer;
net_cls = /cgroup/net_cls;
blkio = /cgroup/blkio;
}

group zorro {
cpu {
cpu.shares = 6000;

cpu.cfs_quota_us = “600000”;

}
cpuset {

cpuset.cpus = “0-7,12-19”;

cpuset.mems = “0-1”;

}
memory {
}
}

配置中添加了一个真对memory的空配置项,我们稍等下再给里面添加配置。

[root@zorrozou-pc ~]# cat /etc/cgrules.conf
zorro cpu,cpuset,cpuacct,memory zorro
jerry cpu,cpuset,cpuacct,memory jerry
shrek cpu,cpuset,cpuacct,memory shrek

文件修改完之后记得重启相关服务:

[root@zorrozou-pc ~]# service cgconfig restart
[root@zorrozou-pc ~]# service cgred restart

让我们继续来看看真对内存都有哪些配置参数:

[root@zorrozou-pc ~]# ls /cgroup/memory/zorro/
cgroup.clone_children memory.kmem.failcnt memory.kmem.tcp.limit_in_bytes memory.max_usage_in_bytes memory.memsw.usage_in_bytes memory.soft_limit_in_bytes
cgroup.event_control memory.kmem.limit_in_bytes memory.kmem.tcp.max_usage_in_bytes memory.meminfo memory.move_charge_at_immigrate memory.stat notify_on_release
cgroup.procs memory.kmem.max_usage_in_bytes memory.kmem.tcp.usage_in_bytes memory.memsw.failcnt memory.numa_stat memory.swappiness tasks
memory.failcnt memory.kmem.slabinfo memory.kmem.usage_in_bytes memory.memsw.limit_in_bytes memory.oom_control memory.usage_in_bytes
memory.force_empty memory.kmem.tcp.failcnt memory.limit_in_bytes memory.memsw.max_usage_in_bytes memory.pressure_level memory.use_hierarchy

首先我们已经认识了memory.stat文件了,这个文件内容不能修改,它实际上是输出当前cgroup相关内存使用信息的。常见的数据及其含义我们刚才也已经说过了,在此不再复述。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

###cgroup内存限制

memory.memsw.limit_in_bytes:内存+swap空间使用的总量限制。

memory.limit_in_bytes:内存使用量限制。

这两项的意义很清楚了,如果你决定在你的cgroup中关闭swap功能,可以把两个文件的内容设置为同样的值即可。至于为什么相信大家都能想清楚。

###OOM控制

memory.oom_control:内存超限之后的oom行为控制。
这个文件中有两个值:

oom_kill_disable 0

默认为0表示打开oom killer,就是说当内存超限时会触发干掉进程。如果设置为1表示关闭oom killer,此时内存超限不会触发内核杀掉进程。而是将进程夯住(hang/sleep),实际上内核中就是将进程设置为D状态,并且将相关进程放到一个叫做OOM-waitqueue的队列中。这时的进程可以kill杀掉。如果你想继续让这些进程执行,可以选择这样几个方法:

  1. 增加内存,让进程有内存可以继续申请。
  2. 杀掉一些进程,让本组内有内存可用。
  3. 把一些进程移到别的cgroup中,让本cgroup内有内存可用。
  4. 删除一些tmpfs的文件,就是占用内存的文件,比如共享内存或者其它会占用内存的文件。

说白了就是,此时只有当cgroup中有更多内存可以用了,在OOM-waitqueue队列中被挂起的进程就可以继续运行了。

under_oom 0

这个值只是用来看的,它表示当前的cgroup的状态是不是已经oom了,如果是,这个值将显示为1。我们就是通过设置和监测这个文件中的这两个值来管理cgroup内存超限之后的行为的。在默认场景下,如果你使用了swap,那么你的cgroup限制内存之后最常见的异常效果是IO变高,如果业务不能接受,我们一般的做法是关闭swap,那么cgroup内存oom之后都会触发kill掉进程,如果我们用的是LXC或者Docker这样的容器,那么还可能干掉整个容器。当然也经常会因为kill进程的时候因为进程处在D状态,而导致整个Docker或者LXC容器根本无法被杀掉。至于原因,在前面已经说的很清楚了。当我们遇到这样的困境时该怎么办?一个好的办法是,关闭oom killer,让内存超限之后,进程挂起,毕竟这样的方式相对可控。此时我们可以检查under_oom的值,去看容器是否处在超限状态,然后根据业务的特点决定如何处理业务。我推荐的方法是关闭部分进程或者重启掉整个容器,因为可以想像,容器技术所承载的服务应该是在整体软件架构上有容错的业务,典型的场景是web服务。容器技术的特点就是生存周期短,在这样的场景下,杀掉几个进程或者几个容器,都应该对整体服务的稳定性影响不大,而且容器的启动速度是很快的,实际上我们应该认为,容器的启动速度应该是跟进程启动速度可以相媲美的。你的业务会因为死掉几个进程而表现不稳定么?如果不会,请放心的干掉它们吧,大不了很快再启动起来就是了。但是如果你的业务不是这样,那么请根据自己的情况来制定后续处理的策略。

当我们进行了内存限制之后,内存超限的发生频率要比使用实体机更多了,因为限制的内存量一般都是小于实际物理内存的。所以,使用基于内存限制的容器技术的服务应该多考虑自己内存使用的情况,尤其是内存超限之后的业务异常处理应该如何让服务受影响的程度降到更低。在系统层次和应用层次一起努力,才能使内存隔离的效果达到最好。

###内存资源审计

memory.memsw.usage_in_bytes:当前cgroup的内存+swap的使用量。

memory.usage_in_bytes:当前cgroup的内存使用量。

memory.max_usage_in_bytes:cgroup的最大内存使用量。

memory.memsw.max_usage_in_bytes:cgroup最大的内存+swap的使用量。

这些文件都是只读的,用来查看相关状态信息,只能看不能改。

如果你的内核配置打开了CONFIG_MEMCG_KMEM选项的话,那么可以看到当前cgroup的内核内存使用的限制和状态统计信息,他们都是以memory.kmem开头的文件。你可以通过memory.kmem.limit_in_bytes来限制内核使用的内存大小,通过memory.kmem.slabinfo来查看内核slab分配器的状态。现在还能通过memory.kmem.tcp开头的文件来限制cgroup中使用tcp协议的内存资源使用和状态查看。

所有名字中有failcnt的文件里面的值都是相关资源超限的次数的计数,可以通过echo 0将这些计数重置。如果你的服务器是NUMA架构的话,可以通过memory.numa_stat这个文件来查看cgroup中的NUMA相关状态。memory.swappiness跟/proc/sys/vm/swappiness的概念一致,用来调整cgroup使用swap的状态,如果大家认真做了本文前面的思考题的话,应该知道这个文件是干嘛的,本文不会详细解释关于swappiness的细节算法,以后将在性能调整系列文章中详细解释相关参数。

###内存软限制以及内存超卖

memory.soft_limit_in_bytes:内存软限制。

如果超过了memory.limit_in_bytes所定义的限制,那么进程会被oom killer干掉或者被暂停,这相当于硬限制,因为进程无法申请超过自身cgroup限制的内存,但是软限制确是可以突破的。我们假定一个场景,如果你的实体机上有四个cgroup,实体机的内存总量是64G,那么一般情况我们会考虑给每个cgroup限制到16G内存。但是现实情况并不会这么理想,首先实体机上其他进程和内核会占用部分内存,这将导致实际上每个cgroup都不会真的有16G内存可用,如果四个cgroup都尽量占用内存的话,他们可能谁都不会到达内存的上限触发超限的行为,这可能将导致进程都抢不到内存而被饿死。类似的情况还可能发上在内存超卖的环境中,比如,我们仍然只有64G内存,但是确开了8个cgroup,每个都限制了16G内存。这样每个cgroup分配的内存之和达到了128G,但是实际内存量只有64G。这种情况是出于绝大多数应用可能不会占用满所有的内存来考虑的,这样就可以把本来属于它的那份内存“借用”给其它cgroup。以上这样的情况都会出现类似的问题,就是,如果全局内存已经耗尽了,但是某些cgroup还没达到他的内存使用上限,而它们此时如果要申请内存的话,此时该从哪里回收内存?如果我们配置了memory.soft_limit_in_bytes,那么内核将去回收那些内存超过了这个软限制的cgroup的内存,尽量缩减它们的内存占用达到软限制的量以下,以便让没有达到软限制的cgroup有内存可以用。当然,在没有这样的内存竞争以及没有达到硬限制的情况下,软限制是不会生效的。还有就是,软限制的起作用时间可能会比较长,毕竟内核要平衡多个cgroup的内存使用。

根据软限制的这些特点,我们应该明白如果想要软限制生效,应该把它的值设置成小于硬限制。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

###进程迁移时的内存charge

memory.move_charge_at_immigrate:打开或者关闭进程迁移时的内存记账信息。

进程可以在多个cgroup之间切换,所以内存限制必须考虑当发生这样的切换时,进程进入的新cgroup中记录的内存使用量是重新从0累计还是把原来cgroup中的信息迁移过来?当这个开关设置为0的时候是关闭这个功能,相当于不累计之前的信息,默认是1,迁移的时候要在新的cgroup中累积(charge)原来信息,并把旧group中的信息给uncharge掉。如果新cgroup中没有足够的空间容纳新来的进程,首先内核会在cgroup内部回收内存,如果还是不够,就会迁移失败。

###内存压力通知机制

最后,内存的资源隔离还提供了一种压力通知机制。当cgoup内的内存使用量达到某种压力状态的时候,内核可以通过eventfd的机制来通知用户程序,这个通知是通过cgroup.event_controlmemory.pressure_level来实现的。使用方法是:

使用eventfd()创建一个eventfd,假设叫做efd,然后open()打开memory.pressure_level的文件路径,产生一个另一个fd,我们暂且叫它cfd,然后将这两个fd和我们要关注的内存压力级别告诉内核,让内核帮我们关注条件是否成立,通知方式就是把以上信息按这样的格式:”<event_fd:efd> “写入cgroup.event_control。然后就可以去等着efd是否可读了,如果能读出信息,则代表内存使用已经触发相关压力条件。

压力级别的level有三个:

“low”:表示内存使用已经达到触发内存回收的压力级别。

“medium”:表示内存使用压力更大了,已经开始触发swap以及将活跃的cache写回文件等操作了。

“critical”:到这个级别,就意味着内存已经达到上限,内核已经触发oom killer了。

程序从efd读出的消息内容就是这三个级别的关键字。我们可以通过这个机制,建立一个内存压力管理系统,在内存达到相应级别的时候,触发响应的管理策略,来达到各种自动化管理的目的。

下面给出一个监控程序的例子:

#include
#include
#include
#include
#include #include #include
#include
#include

#include <sys/eventfd.h>

#define USAGE_STR “Usage: cgroup_event_listener ”

int main(int argc, char **argv)
{
int efd = -1;
int cfd = -1;
int event_control = -1;
char event_control_path[PATH_MAX];
char line[LINE_MAX];
int ret;

if (argc != 3)
errx(1, “%s”, USAGE_STR);

cfd = open(argv[1], O_RDONLY);
if (cfd == -1)
err(1, “Cannot open %s”, argv[1]);

ret = snprintf(event_control_path, PATH_MAX, “%s/cgroup.event_control”,
dirname(argv[1]));
if (ret >= PATH_MAX)
errx(1, “Path to cgroup.event_control is too long”);

event_control = open(event_control_path, O_WRONLY);
if (event_control == -1)
err(1, “Cannot open %s”, event_control_path);

efd = eventfd(0, 0);
if (efd == -1)
err(1, “eventfd() failed”);

ret = snprintf(line, LINE_MAX, “%d %d %s”, efd, cfd, argv[2]);
if (ret >= LINE_MAX)
errx(1, “Arguments string is too long”);

ret = write(event_control, line, strlen(line) + 1);
if (ret == -1)
err(1, “Cannot write to cgroup.event_control”);

while (1) {
uint64_t result;

ret = read(efd, &result, sizeof(result));
if (ret == -1) {
if (errno == EINTR)
continue;
err(1, “Cannot read from eventfd”);
}
assert(ret == sizeof(result));

ret = access(event_control_path, W_OK);
if ((ret == -1) && (errno == ENOENT)) {
puts(“The cgroup seems to have removed.”);
break;
}

if (ret == -1)
err(1, “cgroup.event_control is not accessible any more”);

printf(“%s %s: crossed\n”, argv[1], argv[2]);
}

return 0;

##最后

Linux的内存限制要说的就是这么多了,当我们限制了内存之后,相对于使用实体机,实际上对于应用来说可用内存更少了,所以业务会相对更经常地暴露在内存资源紧张的状态下。相对于虚拟机(kvm,xen),多个cgroup之间是共享内核的,我们可以从内存限制的角度思考一些关于“容器”技术相对于虚拟机和实体机的很多特点:

  1. 内存更紧张,应用的内存泄漏会导致相对更严重的问题。
  2. 容器的生存周期时间更短,如果实体机的开机运行时间是以年计算的,那么虚拟机则是以月计算的,而容器应该跟进程的生存周期差不多,顶多以天为单位。所以,容器里面要跑的应用应该可以被经常重启。
  3. 当有多个cgroup(容器)同时运行时,我们不能再以实体机或者虚拟机对资源的使用的理解来规划整体运营方式,我们需要更细节的理解什么是cache,什么是swap,什么是共享内存,它们会被统计到哪些资源计数中?在内核并不冲突的环境,这些资源都是独立给某一个业务使用的,在理解上即使不是很清晰,也不会造成歧义。但是在cgroup中,我们需要彻底理解这些细节,才能对遇到的情况进行预判,并规划不同的处理策略。

也许我们还可以从中得到更多的理解,大家一起来想喽?

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

Cgroup – 从CPU资源隔离说起

Cgroup – 从CPU资源隔离说起

Zorro] icon

Hi,我是Zorro。这是我的微博地址,我会不定期在这里更新文章,如果你有兴趣,可以来关注我呦。

本文有配套视频演示,一起服用效果更佳。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

另外,我的其他联系方式:

Email: <mini.jerry@gmail.com>

QQ: 30007147

今天我们来谈谈:

##什么是Cgroup?

cgroups,其名称源自控制组群(control groups)的简写,是Linux内核的一个功能,用来限制,控制与分离一个进程组群的资源(如CPU、内存、磁盘输入输出等)。

–引自维基百科:cgroup

引用官方说法总是那么冰冷的让人不好理解,所以我还是稍微解释一下:

一个正在运行着服务的计算机系统,跟我们高中上课的情景还是很相似的。如果把系统中的每个进程理解为一个同学的话,那么班主任就是操作系统的核心(kernel),负责管理班里的同学。而cgroup,就是班主任控制学生行为的一种手段,所以,它起名叫control groups。

既然是一种控制手段,那么cgroup能控制什么呢?当然是资源啦!对于计算机来说,资源大概可以分成以下几个部分:

  • 计算资源
  • 内存资源
  • io资源
  • 网络资源

这就是我们常说的内核四大子系统。当我们学习内核的时候,我们也基本上是围绕这四大子系统进行研究。
我们今天要讨论的,主要是cgroup是如何对系统中的CPU资源进行隔离和分配的。其他资源的控制,我们以后有空再说喽。

##如何看待CPU资源?

由于进程和线程在Linux的CPU调度看来没啥区别,所以本文后续都会用进程这个名词来代表内核的调度对象,一般来讲也包括线程

如果要分配资源,我们必须先搞清楚这个资源是如何存在的,或者说是如何组织的。我想CPU大家都不陌生,我们都在系统中用过各种工具查看过CPU的使用率,比如说以下这个命令和它的输出:

[zorro@zorrozou-pc0 ~]$ mpstat -P ALL 1 1
Linux 4.2.5-1-ARCH (zorrozou-pc0) 2015年12月22日 x86_64 (4 CPU)mt

16时01分08秒 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
16时01分09秒 all 0.25 0.00 0.25 0.00 0.00 0.00 0.00 0.00 0.00 99.50
16时01分09秒 0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
16时01分09秒 1 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
16时01分09秒 2 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
16时01分09秒 3 0.00 0.00 1.00 0.00 0.00 0.00 0.00 0.00 0.00 99.00

Average: CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
Average: all 0.25 0.00 0.25 0.00 0.00 0.00 0.00 0.00 0.00 99.50
Average: 0 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
Average: 1 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
Average: 2 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
Average: 3 0.00 0.00 1.00 0.00 0.00 0.00 0.00 0.00 0.00 99.00

显示的内容具体什么意思,希望大家都能了解,我就不在这细解释了。根据显示内容我们知道,这个计算机有4个cpu核心,目前的cpu利用率几乎是0,就是说系统整体比较闲。

从这个例子大概可以看出,我们对cpu资源的评估一般有两个观察角度:

  • 核心个数
  • 百分比

目前的计算机基本都是多核甚至多cpu系统,一个服务器上存在几个到几十个cpu核心的情况都很常见。所以,从这个角度看,cgroup应该提供一种手段,可以给进程们指定它们可以占用的cpu核心,以此来做到cpu计算资源的隔离。
百分比这个概念我们需要多解释一下:这个百分比究竟是怎么来的呢?难道每个cpu核心的计算能力就像一个带刻度表的水杯一样?一个进程要占用就会占用到它的一定刻度么?

当然不是啦!这个cpu的百分比是按时间比率计算的。基本思路是:一个CPU一般就只有两种状态,要么被占用,要么不被占用。当有多个进程要占用cpu的时候,那么操作系统在一个cpu核心上是进行分时处理的。比如说,我们把一秒钟分成1000份,那么每一份就是1毫秒,假设现在有5个进程都要用cpu,那么我们就让它们5个轮着使用,比如一人一毫秒,那么1秒过后,每个进程只占用了这个CPU的200ms,使用率为20%。整体cpu使用比率为100%。
同理,如果只有一个进程占用,而且它只用了300ms,那么在这一秒的尺度看来,cpu的占用时间是30%。于是显示出来的状态就是占用30%的CPU时间。

这就是内核是如何看待和分配计算资源的。当然实际情况要比这复杂的多,但是基本思路就是这样。Linux内核是通过CPU调度器CFS--完全公平调度器对CPU的时间进行调度的,由于本文的侧重点是cgroup而不是CFS,对这个题目感兴趣的同学可以到这里进一步学习。CFS是内核可以实现真对CPU资源隔离的核心手段,因此,理解清楚CFS对理解清楚CPU资源隔离会有很大的帮助。

##如何隔离CPU资源?

根据CPU资源的组织形式,我们就可以理解cgroup是如何对CPU资源进行隔离的了。

无非也是两个思路,一个是分配核心进行隔离,另一个是分配CPU使用时间进行隔离。

再介绍如何做隔离之前,我们先来介绍一下我们的实验系统环境:没有特殊情况,我们的实验环境都是一台24核心、128G内存的服务器,上面安装的系统可以认为是Centos7.

###搭建测试环境

我们将使用cgconfig服务和cgred服务对cgroup进行配置和使用。我们将配置两个group,一个叫zorro,另一个叫jerry。它们分别也是系统上的两个账户,其中zorro用户所运行的进程都默认在zorro group中进行限制,jerry用户所运行的进程都放到jerry group中进行限制。配置文件内容和配置方法如下:

本文并不对以下配置方法的具体含义做解释,大家只要知道如此配置可以达到相关试验环境要求即可。如果大家对配置的细节感兴趣,可以自行查找相关资料进行学习。

首先添加两个用户,zorro和jerry:

[root@zorrozou-pc ~]# useradd zorro
[root@zorrozou-pc ~]# useradd jerry

修改/etc/cgrules.conf,添加两行内容:

[root@zorrozou-pc ~]# cat /etc/cgrules.conf
zorro cpu,cpuacct zorro
jerry cpu,cpuacct jerry

修改/etc/cgconfig.conf,添加以下内容:

[root@zorrozou-pc ~]# cat /etc/cgconfig.conf
mount {
cpuset = /cgroup/cpuset;
cpu = /cgroup/cpu;
cpuacct = /cgroup/cpuacct;
memory = /cgroup/memory;
devices = /cgroup/devices;
freezer = /cgroup/freezer;
net_cls = /cgroup/net_cls;
blkio = /cgroup/blkio;
}

group zorro {
cpuset {
cpuset.cpus = “1,2”;
}
}

group jerry {
cpuset {
cpuset.cpus = “3,4”;
}
}
重启cgconfig服务和cgred服务:

[root@zorrozou-pc ~]# service cgconfig restart
[root@zorrozou-pc ~]# service cgred restart

根据上面的配置,我们给zorro组合jerry组分别配置了cpuset的隔离设置,那么在cgroup的相关目录下应该出现相关组的配置文件:
本文中所出现的的含义,如无特殊说明都是对应cgroup的控制组,而非用户组身份。
我们可以通过检查相关目录内容来检查一下环境是否配置完成:

[root@zorrozou-pc ~]# ls /cgroup/cpuset/{zorro,jerry}
/cgroup/cpuset/jerry:
cgroup.clone_children cpuset.cpu_exclusive cpuset.mem_exclusive cpuset.memory_pressure cpuset.mems cpuset.stat
cgroup.event_control cpuset.cpuinfo cpuset.mem_hardwall cpuset.memory_spread_page cpuset.sched_load_balance notify_on_release
cgroup.procs cpuset.cpus cpuset.memory_migrate cpuset.memory_spread_slab cpuset.sched_relax_domain_level tasks

/cgroup/cpuset/zorro:
cgroup.clone_children cpuset.cpu_exclusive cpuset.mem_exclusive cpuset.memory_pressure cpuset.mems cpuset.stat
cgroup.event_control cpuset.cpuinfo cpuset.mem_hardwall cpuset.memory_spread_page cpuset.sched_load_balance notify_on_release
cgroup.procs cpuset.cpus cpuset.memory_migrate cpuset.memory_spread_slab cpuset.sched_relax_domain_level tasks

至此,我们的实验环境已经搭建完成。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

###测试用例设计
无论是针对CPU核心的隔离还是针对CPU时间的隔离,我们都需要一个可以消耗大量的CPU运算资源的程序来进行测试,考虑到我们是一个多CPU核心的环境,所以我们的测试用例一定也是一个可以并发使用多个CPU核心的计算型测试用例。针对这个需求,我们首先设计了一个使用多线程并发进行筛质数的简单程序。这个程序可以打印出从100010001到100020000数字范围内的质数有哪些。并发48个工作线程从一个共享的count整型变量中取数进行计算。程序源代码如下:

#include #include
#include

#define NUM 48
#define START 100010001
#define END 100020000

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
static int count = 0;

void *prime(void *p)
{
int n, i, flag;

while (1) {
if (pthread_mutex_lock(&mutex) != 0) {
perror(“pthread_mutex_lock()”);
pthread_exit(NULL);
}
while (count == 0) {
if (pthread_cond_wait(&cond, &mutex) != 0) {
perror(“pthread_cond_wait()”);
pthread_exit(NULL);
}
}
if (count == -1) {
if (pthread_mutex_unlock(&mutex) != 0) {
perror(“pthread_mutex_unlock()”);
pthread_exit(NULL);
}
break;
}
n = count;
count = 0;
if (pthread_cond_broadcast(&cond) != 0) {
perror(“pthread_cond_broadcast()”);
pthread_exit(NULL);
}
if (pthread_mutex_unlock(&mutex) != 0) {
perror(“pthread_mutex_unlock()”);
pthread_exit(NULL);
}
flag = 1;
for (i=2;i<n/2;i++) {
if (n%i == 0) {
flag = 0;
break;
}
}
if (flag == 1) {
printf(“%d is a prime form %d!\n”, n, pthread_self());
}
}
pthread_exit(NULL);
}

int main(void)
{
pthread_t tid[NUM];
int ret, i;

for (i=0;i<NUM;i++) {
ret = pthread_create(&tid[i], NULL, prime, NULL);
if (ret != 0) {
perror(“pthread_create()”);
exit(1);
}
}

for (i=START;i<END;i+=2) {
if (pthread_mutex_lock(&mutex) != 0) {
perror(“pthread_mutex_lock()”);
pthread_exit(NULL);
}
while (count != 0) {
if (pthread_cond_wait(&cond, &mutex) != 0) {
perror(“pthread_cond_wait()”);
pthread_exit(NULL);
}
}
count = i;
if (pthread_cond_broadcast(&cond) != 0) {
perror(“pthread_cond_broadcast()”);
pthread_exit(NULL);
}
if (pthread_mutex_unlock(&mutex) != 0) {
perror(“pthread_mutex_unlock()”);
pthread_exit(NULL);
}
}

if (pthread_mutex_lock(&mutex) != 0) {
perror(“pthread_mutex_lock()”);
pthread_exit(NULL);
}
while (count != 0) {
if (pthread_cond_wait(&cond, &mutex) != 0) {
perror(“pthread_cond_wait()”);
pthread_exit(NULL);
}
}
count = -1;
if (pthread_cond_broadcast(&cond) != 0) {
perror(“pthread_cond_broadcast()”);
pthread_exit(NULL);
}
if (pthread_mutex_unlock(&mutex) != 0) {
perror(“pthread_mutex_unlock()”);
pthread_exit(NULL);
}

for (i=0;i<NUM;i++) { ret = pthread_join(tid[i], NULL); if (ret != 0) { perror(“pthread_join()”); exit(1); } } exit(0); } 我们先来看一下这个程序在不做限制的情况下的执行效果和执行时间: [root@zorrozou-pc ~/test]# time ./prime_thread …… 100019603 is a prime form 2068363008! 100019471 is a prime form 1866938112! 100019681 is a prime form 1934079744! 100019597 is a prime form 1875330816! 100019701 is a prime form 2059970304! 100019657 is a prime form 1799796480! 100019761 is a prime form 1808189184! 100019587 is a prime form 1824974592! 100019659 is a prime form 2076755712! 100019837 is a prime form 1959257856! 100019923 is a prime form 2034792192! 100019921 is a prime form 1908901632! 100019729 is a prime form 1850152704! 100019863 is a prime form -2109106432! 100019911 is a prime form -2125891840! 100019749 is a prime form 2101933824! 100019879 is a prime form 2026399488! 100019947 is a prime form 1942472448! 100019693 is a prime form 1917294336! 100019683 is a prime form 2051577600! 100019873 is a prime form 2110326528! 100019929 is a prime form -2134284544! 100019977 is a prime form 1892116224! real 0m8.945s user 3m32.095s sys 0m0.235s [root@zorrozou-pc ~]# mpstat -P ALL 1 11:21:51 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle 11:21:52 all 99.92 0.00 0.08 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 0 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 1 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 2 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 3 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 4 99.00 0.00 1.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 5 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 6 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 7 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 8 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 9 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 10 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 11 99.01 0.00 0.99 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 12 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 13 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 14 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 15 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 16 99.01 0.00 0.00 0.00 0.99 0.00 0.00 0.00 0.00 0.00 11:21:52 17 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 18 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 19 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 20 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 21 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 22 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 11:21:52 23 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 经过多次测试,程序执行时间基本稳定: [root@zorrozou-pc ~/test]# time ./prime_thread &> /dev/null

real 0m8.953s
user 3m31.950s
sys 0m0.227s
[root@zorrozou-pc ~/test]# time ./prime_thread &> /dev/null

real 0m8.932s
user 3m31.984s
sys 0m0.231s
[root@zorrozou-pc ~/test]# time ./prime_thread &> /dev/null

real 0m8.954s
user 3m31.794s
sys 0m0.224s

所有相关环境都准备就绪,后续我们将在此程序的基础上进行各种隔离的测试。

###针对CPU核心进行资源隔离
针对CPU核心进行隔离,其实就是把要运行的进程绑定到指定的核心上运行,通过让不同的进程占用不同的核心,以达到运算资源隔离的目的。其实对于Linux来说,这种手段并不新鲜,也并不是在引入cgroup之后实现的,早在内核使用O1调度算法的时候,就已经支持通过taskset命令来绑定进程的cpu核心了。

好的,废话少说,我们来看看这在cgroup中是怎么配置的。

其实通过刚才的/etc/cgconfig.conf配置文件的内容,我们已经配置好了针对不同的组占用核心的设置,来回顾一下:

group zorro {
cpuset {
cpuset.cpus = “1,2”;
}
}
这段配置内容就是说,将zorro组中的进程都放在编号为1,2的cpu核心上运行。这里要说明的是,cpu核心的编号一般是从0号开始的。24个核心的服务器编号范围是从0-23.我们可以通过查看/proc/cpuinfo的内容来确定相关物理cpu的个数和核心的个数。我们截取一段来看一下:

[root@zorrozou-pc ~/test]# cat /proc/cpuinfo
processor : 23
vendor_id : GenuineIntel
cpu family : 6
model : 63
model name : Intel(R) Xeon(R) CPU E5-2620 v3 @ 2.40GHz
stepping : 2
microcode : 0x2b
cpu MHz : 2599.968
cache size : 15360 KB
physical id : 1
siblings : 12
core id : 5
cpu cores : 6
apicid : 27
initial apicid : 27
fpu : yes
fpu_exception : yes
cpuid level : 15
wp : yes
flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc aperfmperf eagerfpu pni pclmulqdq dtes64 monitor ds_cpl vmx smx est tm2 ssse3 fma cx16 xtpr pdcm pcid dca sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm abm ida arat epb xsaveopt pln pts dtherm tpr_shadow vnmi flexpriority ept vpid fsgsbase tsc_adjust bmi1 avx2 smep bmi2 erms invpcid
bogomips : 4796.38
clflush size : 64
cache_alignment : 64
address sizes : 46 bits physical, 48 bits virtual
power management:

其中processor : 23就是核心编号,说明我们当前显示的是这个服务器上的第24个核心,physical id : 1表示的是这个核心所在的物理cpu是哪个。这个编号也是从0开始,表示这个核心在第二个物理cpu上。那就意味着,我这个服务器是一个双物理cpu的服务器,那就可能意味着我们的系统时NUMA架构。另外还有一个要注意的是core id : 5这个子段,这里面隐含着一个可能的含义:你的服务器是否开启了超线程。众所周知,开启了超线程的服务器,在系统看来,一个核心会编程两个核心来看待。那么我们再确定一下是否开了超线程,可以grep一下:

[root@zorrozou-pc ~/test]# cat /proc/cpuinfo |grep -e “core id” -e “physical id”
physical id : 0
core id : 0
physical id : 0
core id : 1
physical id : 0
core id : 2
physical id : 0
core id : 3
physical id : 0
core id : 4
physical id : 0
core id : 5
physical id : 1
core id : 0
physical id : 1
core id : 1
physical id : 1
core id : 2
physical id : 1
core id : 3
physical id : 1
core id : 4
physical id : 1
core id : 5
physical id : 0
core id : 0
physical id : 0
core id : 1
physical id : 0
core id : 2
physical id : 0
core id : 3
physical id : 0
core id : 4
physical id : 0
core id : 5
physical id : 1
core id : 0
physical id : 1
core id : 1
physical id : 1
core id : 2
physical id : 1
core id : 3
physical id : 1
core id : 4
physical id : 1
core id : 5

这个内容显示出我的服务器是开启了超线程的,因为有同一个physical id : 1core id : 5可能出现两次,那么就说明这个物理cpu上的5号核心在系统看来出现了2个,那么肯定意味着开了超线程。

我在此要强调超线程这个事情,因为在一个开启了超线程的服务器上运行我们当前的测试用例是很可能得不到预想的结果的。因为从原理上看,超线程技术虽然使cpu核心变多了,但是在本测试中并不能反映出相应的性能提高。我们后续会通过cpuset的资源隔离先来说明一下这个问题,然后在后续的测试中,我们将采用一些手段规避这个问题。

我们先通过一个cpuset的配置来反映一下超线程对本测试的影响,顺便学习一下cgroup的cpuset配置方法。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

  1. 不绑定核心测试:

将/etc/cgconfig.conf文件中zorro组相关配置修改为以下状态,之后重启cgconfig服务:

group zorro {
cpuset {
cpuset.cpus = “0-23”;
cpuset.mems = “0-1”;
}
}

[root@zorrozou-pc ~]# service cgconfig restart

切换用户身份到zorro,并察看zorro组的配置:

[root@zorrozou-pc ~]# su – zorro
[zorro@zorrozou-pc ~]$ cat /cgroup/cpuset/zorro/cpuset.cpus
0-23
zorro用户对应的进程已经绑定在0-23核心上执行,我们看一下执行结果:

[zorro@zorrozou-pc ~/test]$ time ./prime_thread_zorro &> /dev/null

real 0m8.956s
user 3m31.990s
sys 0m0.246s
[zorro@zorrozou-pc ~/test]$ time ./prime_thread_zorro &> /dev/null

real 0m8.944s
user 3m31.956s
sys 0m0.247s

执行速度跟刚才一样,这相当于没绑定的情况。下面,我们对zorro组的进程绑定一半的cpu核心进行测试,先测试绑定0-11号核心,将cpuset.cpus = “0-23”改为cpuset.cpus = “0-11”

请注意每次修改完/etc/cgconfig.conf文件内容都应该重启cgconfig服务,并重新登陆zorro账户。过程不再复述。

将核心绑定到0-11之后的测试结果如下:

[zorro@zorrozou-pc ~/test]$ time ./prime_thread_zorro &> /dev/null

real 0m9.457s
user 1m52.773s
sys 0m0.155s
[zorro@zorrozou-pc ~/test]$ time ./prime_thread_zorro &> /dev/null

real 0m9.460s
user 1m52.589s
sys 0m0.153s

14:52:02 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
14:52:03 all 49.92 0.00 0.08 0.00 0.08 0.00 0.00 0.00 0.00 49.92
14:52:03 0 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
14:52:03 1 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
14:52:03 2 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
14:52:03 3 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
14:52:03 4 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
14:52:03 5 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
14:52:03 6 99.01 0.00 0.99 0.00 0.00 0.00 0.00 0.00 0.00 0.00
14:52:03 7 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
14:52:03 8 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
14:52:03 9 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
14:52:03 10 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
14:52:03 11 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
14:52:03 12 0.00 0.00 0.00 0.00 2.00 0.00 0.00 0.00 0.00 98.00
14:52:03 13 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
14:52:03 14 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
14:52:03 15 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
14:52:03 16 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
14:52:03 17 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
14:52:03 18 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
14:52:03 19 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
14:52:03 20 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
14:52:03 21 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
14:52:03 22 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
14:52:03 23 0.00 0.00 0.99 0.00 0.00 0.00 0.00 0.00 0.00 99.01

此时会发现一个现象,执行的总体时间变化不大,大概慢了0.5秒,但是user时间下降了将近一半。

我们再降核心绑定成0-5,12-17测试一下,就是cpuset.cpus = “0-5,12-17”,测试结果如下:

[zorro@zorrozou-pc ~/test]$ time ./prime_thread_zorro &> /dev/null

real 0m17.821s
user 3m32.425s
sys 0m0.223s
[zorro@zorrozou-pc ~/test]$ time ./prime_thread_zorro &> /dev/null
real 0m17.839s
user 3m32.375s
sys 0m0.223s

15:03:03 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
15:03:04 all 49.94 0.00 0.04 0.00 0.04 0.00 0.00 0.00 0.00 49.98
15:03:04 0 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
15:03:04 1 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
15:03:04 2 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
15:03:04 3 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
15:03:04 4 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
15:03:04 5 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
15:03:04 6 0.00 0.00 0.99 0.00 0.00 0.00 0.00 0.00 0.00 99.01
15:03:04 7 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
15:03:04 8 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
15:03:04 9 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
15:03:04 10 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
15:03:04 11 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
15:03:04 12 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
15:03:04 13 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
15:03:04 14 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
15:03:04 15 99.01 0.00 0.99 0.00 0.00 0.00 0.00 0.00 0.00 0.00
15:03:04 16 99.01 0.00 0.99 0.00 0.00 0.00 0.00 0.00 0.00 0.00
15:03:04 17 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
15:03:04 18 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
15:03:04 19 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
15:03:04 20 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
15:03:04 21 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
15:03:04 22 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
15:03:04 23 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00

这次测试的结果就比较符合我们的常识,看上去cpu核心少了一半,于是执行时间增加了几乎一倍。那么是什么原因导致我们绑定到0-11核心的时候看上去性能没有下降呢?

在此我们不去过多讨论超线程的技术细节,简单来说:0-5核心是属于物理cpu0的6个实际核心,6-11是属于物理cpu1的6个实际核心,当我们使用这12个核心的时候,运算覆盖了两个物理cpu的所有真实核心。而12-17核心是对应0-5核心超线程出来的6个核心,18-23则是对应6-11核心超线程出来的6个。我们的测试应用并不能充分利用超线程之后的运算资源,所以,从我们的测试用例角度看来,只要选择了合适核心,12核跟24核的效果几本差别不大。了解了超线程的这个问题,我们后续的测试过程就要注意对比的环境。从本轮测试看来,我们应该用绑定0-5,12-17的测试结果来参考绑定一半cpu核心的效果,而不是绑定到“0-11”上的结果。从测试结果看,减少一半核心之后,确实让运算时间增加了一倍。

出个两个思考题吧:

  1. 我们发现第二轮绑定0-11核心测试的user时间和绑定0-23的测试时间减少一倍,而real时间几乎没变,这是为什么?

  2. 我们发现第三轮绑定0-5,12-17核心测试的user时间和绑定0-23的测试时间几乎一样,而real时间增加了一倍,这是为什么?

至此,如何使用cgroup的cpuset对cpu核心进行资源分配的方法大家应该学会了,这里需要强调一点:

配置中cpuset.mems = “0-1”这段配置非常重要,它相当于打开cpuset功能的开关,本身的意义是用来配置cpu使用的内存节点的,不配置这个字段的结果将是cpuset.cpus设置无效。字段具体含义,请大家自行补脑。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

###针对CPU时间进行资源隔离

再回顾一下系统对cpu资源的使用方式--分时使用。分时使用要有一个基本本的时间调度单元,这个单元的意思是说,在这样一段时间范围内,我们将多少比例分配给某个进程组。我们刚才举的例子是说1秒钟,但是实际情况是1秒钟这个时间周期对计算机来说有点长。Linux内核将这个时间周期定义放在cgroup相关目录下的一个文件里,这个文件在我们服务器上是:

[root@zorrozou-pc ~]# cat /cgroup/cpu/zorro/cpu.cfs_period_us
100000

这个数字的单位是微秒,就是说,我们的cpu时间周期是100ms。还有一点需要注意的是,这个时间是针对单核来说的。

那么针对cgroup的限制放在哪里呢?

[root@zorrozou-pc ~]# cat /cgroup/cpu/zorro/cpu.cfs_quota_us
-1

就是这个cpu.cfs_quota_us文件。这里的cfs就是完全公平调度器,我们的资源隔离就是靠cfs来实现的。-1表示目前无限制。

限制方法很简单,就是设置cpu.cfs_quota_us这个文件的值,调度器会根据这个值的大小决定进程组在一个时间周期内(即100ms)使用cpu时间的比率。比如这个值我们设置成50000,那么就是时间周期的50%,于是这个进程组只能在一个cpu上占用50%的cpu时间。理解了这个概念,我们就可以思考一下,如果想让我们的进程在24核的服务器上不绑定核心的情况下占用所有核心的50%的cpu时间,该如何设置?计算公式为:

(50% * 100000 * cpu核心数)

在此设置为1200000,我们来试一下。修改cgconfig.conf内容,然后重启cgconfig:

group zorro {
cpu {
cpu.cfs_quota_us = “1200000”;
}
}

[root@zorrozou-pc ~]# service cgconfig restart

测试结果如下:

[zorro@zorrozou-pc ~/test]$ time ./prime_thread_zorro &> /dev/null

real 0m17.322s
user 3m27.116s
sys 0m0.266s
[zorro@zorrozou-pc ~/test]$ time ./prime_thread_zorro &> /dev/null

real 0m17.347s
user 3m27.208s
sys 0m0.260s

16:15:12 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
16:15:13 all 49.92 0.00 0.08 0.00 0.04 0.00 0.00 0.00 0.00 49.96
16:15:13 0 51.49 0.00 0.00 0.00 0.99 0.00 0.00 0.00 0.00 47.52
16:15:13 1 51.49 0.00 0.99 0.00 0.00 0.00 0.00 0.00 0.00 47.52
16:15:13 2 54.46 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 45.54
16:15:13 3 51.52 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 48.48
16:15:13 4 48.51 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 51.49
16:15:13 5 48.04 0.00 0.98 0.00 0.00 0.00 0.00 0.00 0.00 50.98
16:15:13 6 49.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 51.00
16:15:13 7 49.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 51.00
16:15:13 8 49.49 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 50.51
16:15:13 9 49.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 51.00
16:15:13 10 48.00 0.00 1.00 0.00 0.00 0.00 0.00 0.00 0.00 51.00
16:15:13 11 49.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 51.00
16:15:13 12 49.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 51.00
16:15:13 13 49.49 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 50.51
16:15:13 14 49.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 51.00
16:15:13 15 50.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 50.00
16:15:13 16 50.51 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 49.49
16:15:13 17 49.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 51.00
16:15:13 18 50.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 50.00
16:15:13 19 50.50 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 49.50
16:15:13 20 50.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 50.00
16:15:13 21 50.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 50.00
16:15:13 22 50.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 50.00
16:15:13 23 50.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 50.00

我们可以看到,基本跟绑定一半的cpu核心数的效果一样,从这个简单的对比来看,使用cpu核心数绑定的方法和使用cpu分配时间的方法,在隔离上效果几乎是相同的。但是考虑到超线程的影响,我们使用cpu时间比率的方式很可能根cpuset的方式有些差别,为了看到这个差别,我们将针对cpuset和cpuquota进行一个对比测试,测试结果如下表:

cpu比率(核心数) cpuset realtime cpuquota realtime
8.3%(2) 1m46.557s 1m36.786s
16.7%(4) 0m53.271s 0m51.067s
25%(6) 0m35.528s 0m34.539s
33.3%(8) 0m26.643s 0m25.923s
50%(12) 0m17.839s 0m17.347s
66.7%(16) 0m13.384s 0m13.015s
100%(24) 0m8.972s 0m8.932s

思考题时间又到了:请解释这个表格测试得到的数字的差异。

我们现在已经学会了如何使用cpuset和cpuquota两种方式对cpu资源进行分配,但是这两种分配的缺点也是显而易见的--就是分配完之后,进程都最多只能占用相关比例的cpu资源。即使服务器上还有空闲资源,这两种方式都无法将资源“借来使用”。

那么有没有一种方法,既可以保证在系统忙的情况下让cgroup进程组只占用相关比例的资源,而在系统闲的情况下,又可以借用别人的资源,以达到资源利用率最大话的程度呢?当然有!那就是--

###权重CPU资源隔离

这里的权重其实是shares。我把它叫做权重是因为这个值可以理解为对资源占用的权重。这种资源隔离方式事实上也是对cpu时间的进行分配。区别是作用在cfs调度器的权重值上。从用户的角度看,无非就是给每个cgroup配置一个share值,cpu在进行时间分配的时候,按照share的大小比率来确定cpu时间的百分比。它对比cpuquota的优势是,当进程不在cfs可执行调度队列中的时候,这个权重是不起作用的。就是说,一旦其他cgroup的进程释放cpu的时候,正在占用cpu的进程可以全占所有计算资源。而当有多个cgroup进程都要占用cpu的时候,大家按比例分配。

我们照例通过实验来说明这个情况,配置方法也很简单,修改cgconfig.conf,添加字段,并重启服务:

group zorro {
cpu {
cpu.shares = 1000;
}
}

[root@zorrozou-pc ~]# service cgconfig restart

配置完之后,我们就给zorro组配置了一个shares值为1000,但是实际上如果系统中只有这一个组的话,cpu看起来对他是没有限制的。现在的执行效果是这样:

[root@zorrozou-pc ~]# mpstat -P ALL 1

17:17:29 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
17:17:30 all 99.88 0.00 0.12 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 0 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 1 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 2 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 3 99.01 0.00 0.99 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 4 99.00 0.00 1.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 5 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 6 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 7 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 8 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 9 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 10 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 11 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 12 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 13 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 14 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 15 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 16 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 17 99.00 0.00 1.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 18 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 19 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 20 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 21 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 22 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
17:17:30 23 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00

[zorro@zorrozou-pc ~/test]$ time ./prime_thread_zorro &> /dev/null

real 0m8.937s
user 3m32.190s
sys 0m0.225s

如显示,cpu我们是独占的。那么什么时候有隔离效果呢?是系统中有别的cgroup也要占用cpu的时候,就能看出效果了。比如此时我们再添加一个jerry,shares值也配置为1000,并且让jerry组一直有占用cpu的进程在运行。

group jerry {
cpu {
cpu.shares = “1000”;
}
}

top – 17:24:26 up 1 day, 5 min, 2 users, load average: 41.34, 16.17, 8.17
Tasks: 350 total, 2 running, 348 sleeping, 0 stopped, 0 zombie
Cpu0 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu1 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu2 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu3 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu4 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu5 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu6 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu7 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu8 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu9 : 99.7%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.3%hi, 0.0%si, 0.0%st
Cpu10 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu11 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu12 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu13 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu14 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu15 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu16 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu17 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu18 : 99.3%us, 0.7%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu19 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu20 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu21 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu22 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu23 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Mem: 131904480k total, 4938020k used, 126966460k free, 136140k buffers
Swap: 2088956k total, 0k used, 2088956k free, 3700480k cached

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
13945 jerry 20 0 390m 872 392 S 2397.2 0.0 48:42.54 jerry

我们以jerry用户身份执行了一个进程一直100%占用cpu,从上面的显示可以看到,这个进程占用了2400%的cpu,是因为每个cpu核心算100%,24个核心就是2400%。此时我们再以zorro身份执行筛质数的程序,并察看这个程序占用cpu的百分比:

top – 19:44:11 up 1 day, 2:25, 3 users, load average: 60.91, 50.92, 48.85
Tasks: 336 total, 3 running, 333 sleeping, 0 stopped, 0 zombie
Cpu0 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu1 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu2 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu3 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu4 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu5 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu6 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu7 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu8 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu9 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu10 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu11 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu12 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu13 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu14 : 99.7%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.3%hi, 0.0%si, 0.0%st
Cpu15 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu16 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu17 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu18 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu19 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu20 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu21 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu22 : 99.7%us, 0.3%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Cpu23 :100.0%us, 0.0%sy, 0.0%ni, 0.0%id, 0.0%wa, 0.0%hi, 0.0%si, 0.0%st
Mem: 131904480k total, 1471772k used, 130432708k free, 144216k buffers
Swap: 2088956k total, 0k used, 2088956k free, 322404k cached

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
13945 jerry 20 0 390m 872 392 S 1200.3 0.0 3383:04 jerry
9311 zorro 20 0 390m 872 392 R 1197.0 0.0 0:51.56 prime_thread_zo

通过top我们可以看到,以zorro用户身份执行的进程和jerry进程平分了cpu,每人50%。zorro筛质数执行的时间为:

[zorro@zorrozou-pc ~/test]$ time ./prime_thread_zorro &> /dev/null

real 0m15.152s
user 2m58.637s
sys 0m0.220s
[zorro@zorrozou-pc ~/test]$ time ./prime_thread_zorro &> /dev/null

real 0m15.465s
user 3m0.706s
sys 0m0.221s

根据这个时间看起来,基本与通过cpuquota方式分配50%的cpu时间以及通过cpuset方式分配12个核心的情况相当,而且效率还稍微高一些。当然我要说明的是,这里几乎两秒左右的效率的提高并不具备很大的参考性,它与jerry进程执行的运算是有很大相关性的。此时jerry进程执行的是一个多线程的while死循环,占满所有cpu跑。当我们把jerry进程执行的内容同样变成筛质数的时候,zorro用户的进程执行效率的参考值就比较标准了:

[zorro@zorrozou-pc ~/test]$ time ./prime_thread_zorro &> /dev/null

real 0m17.521s
user 3m32.684s
sys 0m0.254s
[zorro@zorrozou-pc ~/test]$ time ./prime_thread_zorro &> /dev/null

real 0m17.597s
user 3m32.682s
sys 0m0.253s

如程序执行显示,执行效率基本与cpuset和cpuquota相当。

这又引发了另一个问题请大家思考:为什么jerry用户执行的运算的逻辑不同会影响zorro用户的运算效率?

我们可以将刚才cpuset和cpuquota的对比列表加入cpushare一列来一起对比了,为了方便参考,我们都以cpuset为基准进行比较:

shares zorro/shares jerry(核心数) cpuset realtime cpushare realtime cpuquota realtime
2000/22000(2) 1m46.557s 1m41.691s 1m36.786s
4000/20000(4) 0m53.271s 0m51.801s 0m51.067s
6000/18000(6) 0m35.528s 0m35.152s 0m34.539s
8000/16000(8) 0m26.643s 0m26.372s 0m25.923s
12000/12000(12) 0m17.839s 0m17.694s 0m17.347s
16000/8000(16) 0m13.384s 0m13.388s 0m13.015s
24000/0(24) 0m8.972s 0m8.943s 0m8.932s

请注意一个问题,由于cpushares无法像cpuquota或者cpuset那样只执行zorro用户的进程,所以在进行cpushares测试的时候,必须让jerry用户同时执行相同的筛质数程序,才能使两个用户分别分到相应比例的cpu时间。这样可能造成本轮测试结果的不准确。通过对比看到,当比率分别都配置了相当于两个核心的计算能力的情况下,本轮测试是cpuquota方式消耗了1m36.786s稍快一些。为了保证相对公平的环境作为参照,我们将重新对这轮测试进行数据采集,这次在cpuset和cpuquota的压测时,都用jerry用户执行一个干扰程序作为参照,重新分析数据。当然,cpushares的测试数据就不必重新测试了:

shares zorro/shares jerry(核心数) cpuset realtime cpushare realtime cpuquota realtime
2000/22000(2) 1m46.758s 1m41.691s 1m42.341s
4000/20000(4) 0m53.340s 0m51.801s 0m51.512s
6000/18000(6) 0m35.525s 0m35.152s 0m34.392s
8000/16000(8) 0m26.738s 0m26.372s 0m25.772s
12000/12000(12) 0m17.793s 0m17.694s 0m17.256s
16000/8000(16) 0m13.366s 0m13.388s 0m13.155s
24000/0(24) 0m8.930s 0m8.943s 0m8.939s

至此,cgroup中针对cpu的三种资源隔离都介绍完了,分析我们的测试数据可以得出一些结论:

  1. 三种cpu资源隔离的效果基本相同,在资源分配比率相同的情况下,它们都提供了差不多相同的计算能力。
  2. cpuset隔离方式是以分配核心的方式进行资源隔离,可以提供的资源分配最小粒度是核心,不能提供更细粒度的资源隔离,但是隔离之后运算的相互影响最低。需要注意的是在服务器开启了超线程的情况下,要小心选择分配的核心,否则不同cgroup间的性能差距会比较大。
  3. cpuquota给我们提供了一种比cpuset可以更细粒度的分配资源的方式,并且保证了cgroup使用cpu比率的上限,相当于对cpu资源的硬限制。
  4. cpushares给我们提供了一种可以按权重比率弹性分配cpu时间资源的手段:当cpu空闲的时候,某一个要占用cpu的cgroup可以完全占用剩余cpu时间,充分利用资源。而当其他cgroup需要占用的时候,每个cgroup都能保证其最低占用时间比率,达到资源隔离的效果。

大家可以根据这三种不同隔离手段特点,针对自己的环境来选择不同的方式进行cpu资源的隔离。当然,这些手段也可以混合使用,以达到更好的QOS效果。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

但是可是but,这就完了么?
显然并没有。。。。。。

以上测试只针对了一种计算场景,这种场景在如此的简单的情况下,影响测试结果的条件已经很复杂了。如果是其他情况呢?我们线上真正跑业务的环境会这么单纯么?显然不会。我们不可能针对所有场景得出结论,想要找到适用于自己场景的隔离方式,还是需要在自己的环境中进行充分测试。在此只能介绍方法,以及针对一个场景的参考数据,仅此而已。单就这一个测试来说,它仍然不够全面,无法体现出内核cpu资源隔离的真正面目。众所周知,cpu使用主要分两个部分,user和sys。上面这个测试,由于测试用例的选择,只关注了user的使用。那么如果我们的sys占用较多会变成什么样呢?

##CPU资源隔离在sys较高的情况下是什么表现?

###内核资源不冲突的情况

首先我们简单说一下什么叫sys较高。先看mpstat命令的输出:

[root@zorrozou-pc ~]# mpstat 1
Linux 3.10.90-1-linux (zorrozou-pc) 12/24/15 x86_64 (24 CPU)

16:08:52 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
16:08:53 all 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
16:08:54 all 0.00 0.00 0.04 0.00 0.04 0.00 0.00 0.00 0.00 99.92
16:08:55 all 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 100.00
16:08:56 all 0.04 0.00 0.04 0.00 0.00 0.00 0.00 0.00 0.00 99.92
16:08:57 all 0.04 0.00 0.04 0.00 0.00 0.00 0.00 0.00 0.00 99.92
16:08:58 all 0.00 0.00 0.04 0.00 0.00 0.00 0.00 0.00 0.00 99.96

Average: all 0.01 0.00 0.03 0.00 0.01 0.00 0.00 0.00 0.00 99.95

这里面我们看到cpu的使用比率分了很多栏目,我们一般评估进程占用CPU的时候,最重要的是%user和%sys。%sys一般是指,进程陷入内核执行时所占用的时间,这些时间是内核在工作。常见的情况时,进程执行过程中之行了某个系统调用,而陷入内核态执行所产生的cpu占用。

所以在这一部分,我们需要重新提供一个测试用例,让sys部分的cpu占用变高。基于筛质数进行改造即可,我们这次让每个筛质数的线程,在做运算之前都用非阻塞方式open()打开一个文件,每次拿到一个数运算的时候,循环中都用系统调用read()读一下文件。以此来增加sys占用时间的比率。先来改程序:

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

#define NUM 48
#define START 1010001
#define END 1020000

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
static int count = 0;

void *prime(void *p)
{
int n, i, flag;
int num, fd, ret;
char name[BUFSIZ];
char buf[BUFSIZ];

bzero(name, BUFSIZ);

num = (int *)p;
sprintf(name, “/tmp/tmpfilezorro%d”, num);

fd = open(name, O_RDWR|O_CREAT|O_TRUNC|O_NONBLOCK , 0644);
if (fd < 0) {
perror(“open()”);
exit(1);
}

while (1) {
if (pthread_mutex_lock(&mutex) != 0) {
perror(“pthread_mutex_lock()”);
pthread_exit(NULL);
}
while (count == 0) {
if (pthread_cond_wait(&cond, &mutex) != 0) {
perror(“pthread_cond_wait()”);
pthread_exit(NULL);
}
}
if (count == -1) {
if (pthread_mutex_unlock(&mutex) != 0) {
perror(“pthread_mutex_unlock()”);
pthread_exit(NULL);
}
break;
}
n = count;
count = 0;
if (pthread_cond_broadcast(&cond) != 0) {
perror(“pthread_cond_broadcast()”);
pthread_exit(NULL);
}
if (pthread_mutex_unlock(&mutex) != 0) {
perror(“pthread_mutex_unlock()”);
pthread_exit(NULL);
}
flag = 1;
for (i=2;i<n/2;i++) {
ret = read(fd, buf, BUFSIZ);
if (ret < 0) {
perror(“read()”);
}
if (n%i == 0) {
flag = 0;
break;
}
}
if (flag == 1) {
printf(“%d is a prime form %d!\n”, n, pthread_self());
}
}

close(fd);
pthread_exit(NULL);
}

int main(void)
{
pthread_t tid[NUM];
int ret, i, num;

for (i=0;i<NUM;i++) {
ret = pthread_create(&tid[i], NULL, prime, (void *)i);
if (ret != 0) {
perror(“pthread_create()”);
exit(1);
}
}

for (i=START;i<END;i+=2) {
if (pthread_mutex_lock(&mutex) != 0) {
perror(“pthread_mutex_lock()”);
pthread_exit(NULL);
}
while (count != 0) {
if (pthread_cond_wait(&cond, &mutex) != 0) {
perror(“pthread_cond_wait()”);
pthread_exit(NULL);
}
}
count = i;
if (pthread_cond_broadcast(&cond) != 0) {
perror(“pthread_cond_broadcast()”);
pthread_exit(NULL);
}
if (pthread_mutex_unlock(&mutex) != 0) {
perror(“pthread_mutex_unlock()”);
pthread_exit(NULL);
}
}
if (pthread_mutex_lock(&mutex) != 0) {
perror(“pthread_mutex_lock()”);
pthread_exit(NULL);
}
while (count != 0) {
if (pthread_cond_wait(&cond, &mutex) != 0) {
perror(“pthread_cond_wait()”);
pthread_exit(NULL);
}
}
count = -1;
if (pthread_cond_broadcast(&cond) != 0) {
perror(“pthread_cond_broadcast()”);
pthread_exit(NULL);
}
if (pthread_mutex_unlock(&mutex) != 0) {
perror(“pthread_mutex_unlock()”);
pthread_exit(NULL);
}

for (i=0;i<NUM;i++) { ret = pthread_join(tid[i], NULL); if (ret != 0) { perror(“pthread_join()”); exit(1); } } exit(0); } 我们将筛质数的范围缩小了两个数量级,并且每个线程都打开一个文件,每次计算的循环中都read一遍。此时这个进程执行的时候的cpu使用状态是这样的: 17:20:46 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle 17:20:47 all 53.04 0.00 46.96 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 0 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 1 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 2 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 3 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 4 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 5 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 6 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 7 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 8 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 9 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 10 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 11 53.47 0.00 46.53 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 12 52.00 0.00 48.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 13 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 14 53.47 0.00 46.53 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 15 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 16 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 17 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 18 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 19 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 20 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 21 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 22 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 17:20:47 23 53.00 0.00 47.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 [zorro@zorrozou-pc ~/test]$ time ./prime_sys &> /dev/null

real 0m12.227s
user 2m34.869s
sys 2m17.239s

测试用例已经基本符合我们的测试条件,可以达到近50%的sys占用,下面开始进行对比测试。测试方法根上一轮一样,仍然用jerry账户运行一个相同的程序在另一个cgroup不断的循环,然后分别看在不同资源分配比率下的zorro用户筛质数程序运行的时间。以下是测试结果:

shares zorro/shares jerry(核心数) cpuset realtime cpushare realtime cpuquota realtime
2000/22000(2) 2m27.666s 2m27.599s 2m27.918s
4000/20000(4) 1m12.621s 1m14.345s 1m13.581s
6000/18000(6) 0m48.612s 0m49.474s 0m48.730s
8000/16000(8) 0m36.412s 0m37.269s 0m36.784s
12000/12000(12) 0m24.611s 0m24.624s 0m24.628s
16000/8000(16) 0m18.401s 0m18.688s 0m18.480s
24000/0(24) 0m12.188s 0m12.487s 0m12.147s
shares zorro/shares jerry(核心数) cpuset systime cpushare systime cpuquota systime
2000/22000(2) 2m20.115s 2m21.024s 2m21.854s
4000/20000(4) 2m16.450s 2m21.103s 2m20.352s
6000/18000(6) 2m18.273s 2m20.455s 2m20.039s
8000/16000(8) 2m18.054s 2m20.611s 2m19.891s
12000/12000(12) 2m20.358s 2m18.331s 2m20.363s
16000/8000(16) 2m17.724s 2m18.958s 2m18.637s
24000/0(24) 2m16.723s 2m17.707s 2m16.176s

这次我们多了一个表格专门记录systime时间占用。根据数据结果我们会发现,在这次测试循环中,三种隔离方式都呈现出随着资源的增加进程是执行的总时间线性下降,并且隔离效果区别不大。由于调用read的次数一样,systime的使用基本都稳定在一个固定的时间范围内。这说明,在sys占用较高的情况下,各种cpu资源隔离手段都表现出比较理想的效果。

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716

###内核资源冲突的情况

但是现实的生产环境往往并不是这么理想的,有没有可能在某种情况下,各种CPU资源隔离的手段并不会表现出这么理想的效果呢?有没有可能不同的隔离方式会导致进程的执行会有影响呢?其实这是很可能发生的。我们上一轮测试中,每个cgroup中的线程打开的文件都不是同一个文件,内核在处理这种场景的时候,并不需要使用内核中的一些互斥资源(比如自旋锁或者屏障)进行竞争条件的处理。如果环境变成大家read的是同一个文件,那么情况就可能有很大不同了。下面我们来测试一下每个zorro组中的所有线程都open同一个文件并且read时的执行效果,我们照例把测试用例代码贴出来:

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

#define NUM 48
#define START 1010001
#define END 1020000

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
static int count = 0;
#define PATH “/etc/passwd”

void *prime(void *p)
{
int n, i, flag;
int num, fd, ret;
char name[BUFSIZ];
char buf[BUFSIZ];

fd = open(PATH, O_RDONLY|O_NONBLOCK);
if (fd < 0) {
perror(“open()”);
exit(1);
}

while (1) {
if (pthread_mutex_lock(&mutex) != 0) {
perror(“pthread_mutex_lock()”);
pthread_exit(NULL);
}
while (count == 0) {
if (pthread_cond_wait(&cond, &mutex) != 0) {
perror(“pthread_cond_wait()”);
pthread_exit(NULL);
}
}
if (count == -1) {
if (pthread_mutex_unlock(&mutex) != 0) {
perror(“pthread_mutex_unlock()”);
pthread_exit(NULL);
}
break;
}
n = count;
count = 0;
if (pthread_cond_broadcast(&cond) != 0) {
perror(“pthread_cond_broadcast()”);
pthread_exit(NULL);
}
if (pthread_mutex_unlock(&mutex) != 0) {
perror(“pthread_mutex_unlock()”);
pthread_exit(NULL);
}
flag = 1;
for (i=2;i<n/2;i++) {
ret = read(fd, buf, BUFSIZ);
if (ret < 0) {
perror(“read()”);
}
if (n%i == 0) {
flag = 0;
break;
}
}
if (flag == 1) {
printf(“%d is a prime form %d!\n”, n, pthread_self());
}
}

close(fd);
pthread_exit(NULL);
}

int main(void)
{
pthread_t tid[NUM];
int ret, i, num;

for (i=0;i<NUM;i++) {
ret = pthread_create(&tid[i], NULL, prime, (void *)i);
if (ret != 0) {
perror(“pthread_create()”);
exit(1);
}
}

for (i=START;i<END;i+=2) {
if (pthread_mutex_lock(&mutex) != 0) {
perror(“pthread_mutex_lock()”);
pthread_exit(NULL);
}
while (count != 0) {
if (pthread_cond_wait(&cond, &mutex) != 0) {
perror(“pthread_cond_wait()”);
pthread_exit(NULL);
}
}
count = i;
if (pthread_cond_broadcast(&cond) != 0) {
perror(“pthread_cond_broadcast()”);
pthread_exit(NULL);
}
if (pthread_mutex_unlock(&mutex) != 0) {
perror(“pthread_mutex_unlock()”);
pthread_exit(NULL);
}
}
if (pthread_mutex_lock(&mutex) != 0) {
perror(“pthread_mutex_lock()”);
pthread_exit(NULL);
}
while (count != 0) {
if (pthread_cond_wait(&cond, &mutex) != 0) {
perror(“pthread_cond_wait()”);
pthread_exit(NULL);
}
}
count = -1;
if (pthread_cond_broadcast(&cond) != 0) {
perror(“pthread_cond_broadcast()”);
pthread_exit(NULL);
}
if (pthread_mutex_unlock(&mutex) != 0) {
perror(“pthread_mutex_unlock()”);
pthread_exit(NULL);
}

for (i=0;i<NUM;i++) {
ret = pthread_join(tid[i], NULL);
if (ret != 0) {
perror(“pthread_join()”);
exit(1);
}
}

exit(0);
}

此时jerry组中的所有线程仍然是每个线程一个文件,与上一轮测试一样。测试结果如下:

shares zorro/shares jerry(核心数) cpuset realtime cpushare realtime cpuquota realtime
2000/22000(2) 2m27.402s 2m41.015s 4m37.149s
4000/20000(4) 1m18.178s 1m25.214s 2m42.455s
6000/18000(6) 0m52.592s 1m2.691s 1m48.492s
8000/16000(8) 0m43.598s 0m57.000s 1m21.044s
12000/12000(12) 0m52.182s 0m59.613s 0m58.004s
16000/8000(16) 0m50.712s 0m54.371s 0m56.911s
24000/0(24) 0m50.599s 0m50.550s 0m50.496s
shares zorro/shares jerry(核心数) cpuset systime cpushare systime cpuquota systime
2000/22000(2) 2m19.829s 2m47.706s 6m39.800s
4000/20000(4) 2m41.928s 3m6.575s 8m14.087s
6000/18000(6) 2m45.671s 3m38.722s 8m13.668s
8000/16000(8) 3m14.434s 4m54.451s 8m12.904s
12000/12000(12) 7m39.542s 9m7.751s 8m57.332s
16000/8000(16) 10m47.425s 11m41.443s 12m21.056s
24000/0(24) 17m17.661s 17m7.311s 17m14.788s

观察这轮测试的结果我们会发现,当线程同时read同一个文件时,时间的消耗并不在呈现线性下降的趋势了,而且,随着分配的资源越来越多,sys占用时间也越来越高,这种现象如何解释呢?本质上来讲,使用cgroup进行资源隔离时,内核资源仍然是共享的。如果业务使用内核资源如果没有产生冲突,那么隔离效果应该会比较理想,但是业务一旦使用了会导致内核资源冲突的逻辑时,那么业务的执行效率就会下降,此时可能所有进程在内核中处理的时候都可能会在竞争的资源上忙等(如果使用了spinlock)。自然的,如果多个cgroup的进程之间也正好使用了可能会导致内核触发竞争条件的资源时,自然也会发生所谓的cgroup之间的相互影响。可能的现象就是,当某一个业务A的cgroup正在运行着,突然B业务的cgroup有请求要处理,会导致A业务的响应速度和处理能力下降。而这种相互干扰,正是资源隔离手段想要尽量避免的。我们认为,如果出现了上述效果,那么资源隔离手段就是打了折扣的。

根据我们的实验结果可以推论,在内核资源有竞争条件的情况下,cpuset的资源隔离方式表现出了相对其他方式的优势,cpushare方式的性能折损尚可接受,而cpuquota表现出了最差的性能,或者说在cpuquota的隔离条件下,cgroup之间进程相互影响的可能性最大。

那么在内核资源存在竞争的时候,cgroup的cpu资源隔离会有相互干扰。结论就是这样了么?这个推断靠谱么?我们再来做一轮实验,这次只对比cpuset和cpuquota。这次我们不用jerry来运行干扰程序测试隔离性,我们让zorro只在单纯的隔离状态下,再有内核资源竞争的条件下进行运算效率测试,就是说这个环境没有多个cgroup可能造成的相互影响。先来看数据:

cpu比率(核心数) cpuset realtime cpuquota realtime
8.3%(2) 2m26.815s 9m4.490s
16.7%(4) 1m17.894s 4m49.167s
25%(6) 0m52.356s 3m13.144s
33.3%(8) 0m42.946s 2m23.010s
50%(12) 0m52.014s 1m33.571s
66.7%(16) 0m50.903s 1m10.553s
100%(24) 0m50.331s 0m50.304s
cpu比率(核心数) cpuset systime cpuquota systime
8.3%(2) 2m18.713s 15m27.738s
16.7%(4) 2m41.172s 16m30.741s
25%(6) 2m44.618s 16m30.964s
33.3%(8) 3m12.587s 16m18.366s
50%(12) 7m36.929s 15m55.407s
66.7%(16) 10m49.327s 16m1.463s
100%(24) 17m9.482s 17m9.533s

不知道看完这组数据之后,大家会不会困惑?cpuset的测试结果根上一轮基本一样,这可以理解。但是为什么cpuquota这轮测试反倒比刚才有jerry用户进程占用cpu进行干扰的时候的性能更差了?

如果了解了内核在这种资源竞争条件的原理的话,这个现象并不难解释。可以这样想,如果某一个资源存在竞争的话,那么是不是同时竞争的人越多,那么对于每个人来说,单次得到资源的可能性更低?比如说,老师给学生发苹果,每次只发一个,但是同时有10个人一起抢,每个人每次抢到苹果的几率是10%,如果20个人一起抢,那么每次每人强到苹果的几率就只有5%了。在内核竞争条件下,也是一样的道理,资源只有一个,当抢的进程少的时候,每个进程抢到资源的概率大,于是浪费在忙等上的时间就少。本轮测试的cpuset就可以说明这个现象,可以观察到,cpuset systime随着分配的核心数的增多而上升,就是同时跑的进程越多,sys消耗在忙等资源上的时间就越大。而cpuquota systime消耗从头到尾都基本变化不大,意味着再以quota方式分配cpu的时候,所有核心都是用得上的,所以一直都有24个进程在抢资源,大家消耗在忙等上的时间是一样的。
为什么有jerry进程同时占用cpu的情况下,cpuquota反倒效率要快些呢?这个其实也好理解。在jerry进程执行的时候,这个cgroup的相关线程打开的是不同的文件,所以从内核竞争上没有冲突。另外,jerry消耗了部分cpu,导致内核会在zorro的进程和jerry的进程之间发生调度,这意味着,同一时刻核心数只有24个,可能有18个在给jerry的线程使用,另外6个在给zorro的进程使用,这导致zorro同时争抢资源的进程个数不能始终保持24个,所以内核资源冲突反倒减小了。这导致,使用cpuquota的情况下,有其他cgroup执行的时候,还可能会使某些业务的执行效率提升,而不是下降。这种相互影响实在太让人意外了!但这确实是事实!

那么什么情况下会导致cgroup之间的相互影响使性能下降呢?也好理解,当多个cgroup的应用之间使用了相同的内核资源的时候。请大家思考一个问题:现实情况是同一种业务使用冲突资源的可能性更大还是不同业务使用冲突资源的可能性更大呢?从概率上说应该是同一种业务。从这个角度出发来看,如果我们有两台多核服务器,有两个跟我们测试逻辑类似的业务A、B,让你选择一种部署方案,你是选择让A、B两个业务分别独占一个服务器?还是让A、B业务使用资源隔离分别在两个服务器上占用50%的资源?通过这轮分析我想答案很明确了:

  1. 从容灾的角度说,让某一个业务使用多台服务器肯定会增加容灾能力。
  2. 从资源利用率的角度说,如果让一个业务放在一个服务器上,那么他在某些资源冲突的情况下并不能发挥会最大效率。然而如果使用group分布在两个不同的服务器上,无论你用cpuset,还是cpushare,又或是cpuquota,它的cpu性能表现都应该强于在一个独立的服务器上部署。况且cgroup的cpu隔离是在cfs中实现的,这种隔离几乎是不会浪费额外的计算能力的,就是说,做隔离相比不做隔离,系统本身的性能损耗都可以忽略不计。

那么,究竟还有什么会妨碍我们使用cgoup的cpu资源隔离呢?

您的支持是我持续写下去的动力,所以本文无耻的接受捐赠。如果你觉得值得,请刷下面二维码,捐赠九毛九。

mm_facetoface_collect_qrcode_1465221734716