Linux的进程间通信-消息队列

Linux的进程间通信-消息队列

版权声明:

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

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

微博ID:orroz

微信公众号:Linux系统技术

前言

Linux系统给我们提供了一种可以发送格式化数据流的通信手段,这就是消息队列。使用消息队列无疑在某些场景的应用下可以大大减少工作量,相同的工作如果使用共享内存,除了需要自己手工构造一个可能不够高效的队列外,我们还要自己处理竞争条件和临界区代码。而内核给我们提供的消息队列,无疑大大方便了我们的工作。

Linux环境提供了XSI和POSIX两套消息队列,本文将帮助您掌握以下内容:

  1. 如何使用XSI消息队列。
  2. 如何使用POSIX消息队列。
  3. 它们的底层实现分别是什么样子的?
  4. 它们分别有什么特点?以及相关资源限制。

请任意打赏,多谢多谢!

收钱

XSI消息队列

系统提供了四个方法来操作XSI消息队列,它们分别是:

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

int msgget(key_t key, int msgflg);

int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);

ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);

int msgctl(int msqid, int cmd, struct msqid_ds *buf);

我们可以使用msgget去创建或访问一个消息队列,与其他XSI IPC一样,msgget使用一个key作为创建消息队列的标识。这个key可以通过ftok生成或者指定为IPC_PRIVATE。指定为IPC_PRIVATE时,此队列会新建出来,而且内核会保证新建的队列key不会与已经存在的队列冲突,所以此时后面的msgflag应指定为IPC_CREAT。当msgflag指定为IPC_CREAT时,msgget会去试图创建一个新的消息队列,除非指定key的消息队列已经存在。可以使用O_CREAT | O_EXCL在指定key已经存在的情况下报错,而不是访问这个消息队列。我们来看创建一个消息队列的例子:

[zorro@zorro-pc mqueue]$ cat msg_create.c
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdlib.h>
#include <stdio.h>

#define FILEPATH "/etc/passwd"
#define PROJID 1234

int main()
{
    int msgid;
    key_t key;
    struct msqid_ds msg_buf;

    key = ftok(FILEPATH, PROJID);
    if (key == -1) {
        perror("ftok()");
        exit(1);
    }

    msgid = msgget(key, IPC_CREAT|IPC_EXCL|0600);
    if (msgid == -1) {
        perror("msgget()");
        exit(1);
    }

    if (msgctl(msgid, IPC_STAT, &msg_buf) == -1) {
        perror("msgctl()");
        exit(1);
    }

    printf("msgid: %d\n", msgid);
    printf("msg_perm.uid: %d\n", msg_buf.msg_perm.uid);
    printf("msg_perm.gid: %d\n", msg_buf.msg_perm.gid);
    printf("msg_stime: %d\n", msg_buf.msg_stime);
    printf("msg_rtime: %d\n", msg_buf.msg_rtime);
    printf("msg_qnum: %d\n", msg_buf.msg_qnum);
    printf("msg_qbytes: %d\n", msg_buf.msg_qbytes);
}

这个程序可以创建并查看一个消息队列的相关状态,执行结果:

[zorro@zorro-pc mqueue]$ ./msg_create 
msgid: 0
msg_perm.uid: 1000
msg_perm.gid: 1000
msg_stime: 0
msg_rtime: 0
msg_qnum: 0
msg_qbytes: 16384

如果我们在次执行这个程序,就会报错,因为key没有变化,我们使用了IPC_CREAT|IPC_EXCL,所以相关队列已经存在了就会报错:

[zorro@zorro-pc mqueue]$ ./msg_create 
msgget(): File exists

顺便看一下msgctl方法,我们可以用它来取一个消息队列的相关状态。更详细的信息可以man 2 msgctl查看。除了查看队列状态以外,还可以使用msgctl设置相关队列状态以及删除指定队列。另外我们还可以使用ipcs -q命令查看系统中XSI消息队列的相关状态。其他相关参数请参考man ipcs。

使用msgsnd和msgrcv向队列发送和从队列接收消息。我们先来看看如何访问一个已经存在的消息队列和向其发送消息:

[zorro@zorro-pc mqueue]$ cat msg_send.c
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

#define FILEPATH "/etc/passwd"
#define PROJID 1234
#define MSG "hello world!"

struct msgbuf {
    long mtype;
    char mtext[BUFSIZ];
};


int main()
{
    int msgid;
    key_t key;
    struct msgbuf buf;

    key = ftok(FILEPATH, PROJID);
    if (key == -1) {
        perror("ftok()");
        exit(1);
    }

    msgid = msgget(key, 0);
    if (msgid == -1) {
        perror("msgget()");
        exit(1);
    }

    buf.mtype = 1;
    strncpy(buf.mtext, MSG, strlen(MSG));
    if (msgsnd(msgid, &buf, strlen(buf.mtext), 0) == -1) {
        perror("msgsnd()");
        exit(1);
    }
}

使用msgget访问一个已经存在的消息队列时,msgflag指定为0即可。使用msgsnd发送消息时主要需要注意的是它的第二个和第三个参数。第二个参数用来指定要发送的消息,它实际上应该是一个指向某个特殊结构的指针,这个结构可以定义如下:

struct msgbuf {
    long mtype;
    char mtext[BUFSIZ];
};

这个结构的mtype实际上是用来指定消息类型的,可以指定的数字必需是个正整数。我们可以把这个概念理解为XSI消息队列对消息优先级的实现方法,即:需要传送的消息体的第一个long长度是用来指定类型的参数,而非消息本身,后面的内容才是消息。在我们实现的消息中,这个结构题可以传送的最大消息长度为BUFSIZE的字节数。当然,如果你的消息并不是一个字符串,也可以将mtype后面的信息实现成各种需要的格式,比如想要发送一个人的名字和他的数学语文成绩的话,可以这样实现:

struct msgbuf {
    long mtype;
    char name[NAMESIZE];
    int math, chinese;
};

这实际上就是让使用者自己去设计一个通讯协议,然后发送端和接收端使用约定好的协议进行通讯。msgsnd的第三个参数应该是这个消息结构体除了mtype以外的真实消息的长度,而不是这个结构题的总长度,这点是要注意的。所以,如果你定义了一个很复杂的消息协议的话,建议的长度写法是这样:

sizeof(buf)-sizeof(long)

msgsnd的最后一个参数可以用来指定IPC_NOWAIT。在消息队列满的情况下,默认的发送行为会阻塞等待,如果加了这个参数,则不会阻塞,而是立即返回,并且errno设置为EAGAIN。然后我们来看接收消息和删除消息队列的例子:

[zorro@zorro-pc mqueue]$ cat msg_receive.c
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

#define FILEPATH "/etc/passwd"
#define PROJID 1234

struct msgbuf {
    long mtype;
    char mtext[BUFSIZ];
};


int main()
{
    int msgid;
    key_t key;
    struct msgbuf buf;

    key = ftok(FILEPATH, PROJID);
    if (key == -1) {
        perror("ftok()");
        exit(1);
    }

    msgid = msgget(key, 0);
    if (msgid == -1) {
        perror("msgget()");
        exit(1);
    }

    if (msgrcv(msgid, &buf, BUFSIZ, 1, 0) == -1) {
        perror("msgrcv()");
        exit(1);
    }

    printf("mtype: %d\n", buf.mtype);
    printf("mtype: %s\n", buf.mtext);

    if (msgctl(msgid, IPC_RMID, NULL) == -1) {
        perror("msgctl()");
        exit(1);
    }

    exit(0);
}

msgrcv会将消息从指定队列中删除,并将其内容填到其第二个参数指定的buf地址所在的内存中。第三个参数指定承接消息的buf长度,如果消息内容长度大于指定的长度,那么这个函数的行为将取决于最后一个参数msgflag是否设置了MSG_NOERROR,如果这个标志被设定,那消息将被截短,消息剩余部分将会丢失。如果没设置这个标志,msgrcv会失败返回,并且errno被设定为E2BIG。

第四个参数用来指定从消息队列中要取的消息类型msgtyp,如果设置为0,则无论什么类型,取队列中的第一个消息。如果值大于0,则读取符合这个类型的第一个消息,当最后一个参数msgflag设置为MSG_EXCEPT的时候,是对消息类型取逻辑非。即,不等于这个消息类型的第一个消息会被读取。如果指定一个小于0的值,那么将读取消息类型比这个负数的绝对值小的类型的所有消息中的第一个。

最后一个参数msgflag还可以设置为:

IPC_NOWAIT:非阻塞方式读取。当队列为空的时候,msgrcv会阻塞等待。加这个标志后将直接返回,errno被设置为ENOMSG。

MSG_COPY:从Linux 3.8之后开始支持以消息位置的方式读取消息。如果标志为置为MSG_COPY则表示启用这个功能,此时msgtyp的含义将从类型变为位置偏移量,第一个消息的起始值为0。如果指定位置的消息不存在,则返回并设置errno为ENOMSG。并且MSG_COPY和MSG_EXCEPT不能同时设置。另外还要注意这个功能需要内核配置打开CONFIG_CHECKPOINT_RESTORE选项。这个选项默认应该是不开的。

使用msgctl删除消息队列的方法比较简单,不在复述。另外关于msgctl的其他使用,请大家参考msgctl的手册。这部分内容的另外一个权威参考资料就是《UNIX环境高级编程》。我们在这里补充一下Linux系统对XSI消息队列的限制相关参数介绍:

/proc/sys/kernel/msgmax:这个文件限制了系统中单个消息最大的字节数。

/proc/sys/kernel/msgmni:这个文件限制了系统中可创建的最大消息队列个数。

/proc/sys/kernel/msgmnb:这个文件用来限制单个消息队列中可以存放的最大消息字节数。

以上文件都可以使用echo或者sysctl命令进行修改。

POSIX消息队列

POSIX消息队列是独立于XSI消息队列的一套新的消息队列API,让进程可以用消息的方式进行数据交换。这套消息队列在Linux 2.6.6版本之后开始支持,还需要你的glibc版本必须高于2.3.4。它们使用如下方法进行操作和控制:

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

mqd_t mq_open(const char *name, int oflag);
mqd_t mq_open(const char *name, int oflag, mode_t mode, struct mq_attr *attr);

类似对文件的open,我们可以用mq_open来打开一个已经创建的消息队列或者创建一个消息队列。这个函数返回一个叫做mqd_t类型的返回值,其本质上还是一个文件描述符,只是在这这里被叫做消息队列描述符(message queue descriptor),在进程里使用这个描述符对消息队列进程操作。所有被创建出来的消息队列在系统中都有一个文件与之对应,这个文件名是通过name参数指定的,这里需要注意的是:name必须是一个以”/”开头的字符串,比如我想让消息队列的名字叫”message”,那么name应该给的是”/message”。消息队列创建完毕后,会在/dev/mqueue目录下产生一个以name命名的文件,我们还可以通过cat这个文件来看这个消息队列的一些状态信息。其它进程在消息队列已经存在的情况下就可以通过mp_open打开名为name的消息队列来访问它。

int mq_send(mqd_t mqdes, const char *msg_ptr, size_t msg_len, unsigned int msg_prio);

int mq_timedsend(mqd_t mqdes, const char *msg_ptr, size_t msg_len, unsigned int msg_prio, const struct timespec *abs_timeout);

ssize_t mq_receive(mqd_t mqdes, char *msg_ptr, size_t msg_len, unsigned int *msg_prio);

ssize_t mq_timedreceive(mqd_t mqdes, char *msg_ptr, size_t msg_len, unsigned int *msg_prio, const struct timespec *abs_timeout);

在一个消息队列创建完毕之后,我们可以使用mq_send来对消息队列发送消息,mq_receive来对消息队列接收消息。正常的发送消息一般不会阻塞,除非消息队列处在某种异常状态或者消息队列已满的时候,而消息队列在空的时候,如果使用mq_receive去试图接受消息的行为也会被阻塞,所以就有必要为两个方法提供一个带超时时间的版本。这里要注意的是msg_prio这个参数,是用来指定消息优先级的。每个消息都有一个优先级,取值范围是0到sysconf(_SC_MQ_PRIO_MAX) – 1的大小。在Linux上,这个值为32768。默认情况下,消息队列会先按照优先级进行排序,就是msg_prio这个值越大的越先出队列。同一个优先级的消息按照fifo原则处理。在mq_receive方法中的msg_prio是一个指向int的地址,它并不是用来指定取的消息是哪个优先级的,而是会将相关消息的优先级取出来放到相关变量中,以便用户自己处理优先级。

int mq_close(mqd_t mqdes);

我们可以使用mq_close来关闭一个消息队列,这里的关闭并非删除了相关文件,关闭之后消息队列在系统中依然存在,我们依然可以继续打开它使用。这跟文件的close和unlink的概念是类似的。

int mq_unlink(const char *name);

使用mq_unlink真正删除一个消息队列。另外,我们还可以使用mq_getattr和mq_setattr来查看和设置消息队列的属性,其函数原型为:

int mq_getattr(mqd_t mqdes, struct mq_attr *attr);

int mq_setattr(mqd_t mqdes, const struct mq_attr *newattr, struct mq_attr *oldattr);

mq_attr结构体是这样的结构:

struct mq_attr {
    long mq_flags;       /* 只可以通过此参数将消息队列设置为是否非阻塞O_NONBLOCK */
    long mq_maxmsg;      /* 消息队列的消息数上限 */
    long mq_msgsize;     /* 消息最大长度 */
    long mq_curmsgs;     /* 消息队列的当前消息个数 */
};

消息队列描述符河文件描述符一样,当进程通过fork打开一个子进程后,子进程中将从父进程继承相关描述符。此时父子进程中的描述符引用的是同一个消息队列,并且它们的mq_flags参数也将共享。下面我们使用几个简单的例子来看看他们的操作方法:

创建并向消息队列发送消息:

[zorro@zorro-pc mqueue]$ cat send.c
#include <fcntl.h>
#include <sys/stat.h>        /* For mode constants */
#include <mqueue.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>

#define MQNAME "/mqtest"


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

    mqd_t mqd;
    int ret;

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

    mqd = mq_open(MQNAME, O_RDWR|O_CREAT, 0600, NULL);
    if (mqd == -1) {
        perror("mq_open()");
        exit(1);
    }

    ret = mq_send(mqd, argv[1], strlen(argv[1]), atoi(argv[2]));
    if (ret == -1) {
        perror("mq_send()");
        exit(1);
    }

    exit(0);
}

注意相关方法在编译的时候需要链接一些库,所以我们可以创建Makefile来解决这个问题:

[zorro@zorro-pc mqueue]$ cat Makefile 
CFLAGS+=-lrt -lpthread

我们添加了rt和pthread库,为以后的例子最好准备。当然大家也可以直接使用gcc -lrt -lpthread来解决这个问题,然后我们对程序编译并测试:

[zorro@zorro-pc mqueue]$ rm send
[zorro@zorro-pc mqueue]$ make send
cc -lrt -lpthread    send.c   -o send
[zorro@zorro-pc mqueue]$ ./send zorro 1
[zorro@zorro-pc mqueue]$ ./send shrek 2
[zorro@zorro-pc mqueue]$ ./send jerry 3
[zorro@zorro-pc mqueue]$ ./send zzzzz 1
[zorro@zorro-pc mqueue]$ ./send ssssss 2
[zorro@zorro-pc mqueue]$ ./send jjjjj 3

我们以不同优先级给消息队列添加了几条消息。然后我们可以通过文件来查看相关消息队列的状态:

[zorro@zorro-pc mqueue]$ cat /dev/mqueue/mqtest 
QSIZE:31         NOTIFY:0     SIGNO:0     NOTIFY_PID:0 

然后我们来看如何接收消息:

[zorro@zorro-pc mqueue]$ cat recv.c
#include <fcntl.h>
#include <sys/stat.h>        /* For mode constants */
#include <mqueue.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>

#define MQNAME "/mqtest"


int main()
{

    mqd_t mqd;
    int ret;
    int val;
    char buf[BUFSIZ];

    mqd = mq_open(MQNAME, O_RDWR);
    if (mqd == -1) {
        perror("mq_open()");
        exit(1);
    }

    ret = mq_receive(mqd, buf, BUFSIZ, &val);
    if (ret == -1) {
        perror("mq_send()");
        exit(1);
    }

    ret = mq_close(mqd);
    if (ret == -1) {
        perror("mp_close()");
        exit(1);
    }

    printf("msq: %s, prio: %d\n", buf, val);

    exit(0);
}

直接编译执行:

[zorro@zorro-pc mqueue]$ ./recv 
msq: jerry, prio: 3
[zorro@zorro-pc mqueue]$ ./recv 
msq: jjjjj, prio: 3
[zorro@zorro-pc mqueue]$ ./recv 
msq: shrek, prio: 2
[zorro@zorro-pc mqueue]$ ./recv 
msq: ssssss, prio: 2
[zorro@zorro-pc mqueue]$ ./recv 
msq: zorro, prio: 1
[zorro@zorro-pc mqueue]$ ./recv 
msq: zzzzz, prio: 1

可以看到优先级对消息队列内部排序的影响。然后是删除这个消息队列:

[zorro@zorro-pc mqueue]$ cat rmmq.c 
#include <fcntl.h>
#include <sys/stat.h>        /* For mode constants */
#include <mqueue.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>

#define MQNAME "/mqtest"


int main()
{

    int ret;

    ret = mq_unlink(MQNAME);
    if (ret == -1) {
        perror("mp_unlink()");
        exit(1);
    }

    exit(0);
}

大家在从消息队列接收消息的时候会发现,当消息队列为空的时候,mq_receive会阻塞,直到有人给队列发送了消息才能返回并继续执行。在很多应用场景下,这种同步处理的方式会给程序本身带来性能瓶颈。为此,POSI消息队列使用mq_notify为处理过程增加了一个异步通知机制。使用这个机制,我们就可以让队列在由空变成不空的时候触发一个异步事件,通知调用进程,以便让进程可以在队列为空的时候不用阻塞等待。这个方法的原型为:

int mq_notify(mqd_t mqdes, const struct sigevent *sevp);

其中sevp用来想内核注册具体的通知行为,可以man 7 sigevent查看相关帮助。这里我们不展开讲解,详细内容将在信号相关内容中详细说明。简单来说,我们可以使用nq_notify方法注册3种行为:SIGEV_NONE,SIGEV_SIGNAL和SIGEV_THREAD。它们分别的含义如下:

SIGEV_NONE:一个“空”提醒。其实就是不提醒。

SIGEV_SIGNAL:当队列中有了消息后给调用进程发送一个信号。可以使用struct sigevent结构体中的sigev_signo指定信号编号,信号的si_code字段将设置为SI_MESGQ以标示这是消息队列的信号。还可以通过si_pid和si_uid来指定信号来自什么pid和什么uid。

SIGEV_THREAD:当队列中有了消息后触发产生一个线程。当设置为线程时,可以使用struct sigevent结构体中的sigev_notify_function指定具体触发什么线程,使用sigev_notify_attributes设置线程属性,使用sigev_value.sival_ptr传递一个任何东西的指针。

我们先来看使用信号的简单例子:

[zorro@zorro-pc mqueue]$ cat notify_sig.c
#include <pthread.h>
#include <mqueue.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>

static mqd_t mqdes;

void mq_notify_proc(int sig_num)
{
    /* mq_notify_proc()是信号处理函数,
    当队列从空变成非空时,会给本进程发送信号,
    触发本函数执行。 */

    struct mq_attr attr;
    void *buf;
    ssize_t size;
    int prio;
    struct sigevent sev;

    /* 我们约定使用SIGUSR1信号进行处理,
    在此判断发来的信号是不是SIGUSR1。 */
    if (sig_num != SIGUSR1) {
        return;
    }

    /* 取出当前队列的消息长度上限作为缓存空间大小。 */
    if (mq_getattr(mqdes, &attr) < 0) {
        perror("mq_getattr()");
        exit(1);
    }

    buf = malloc(attr.mq_msgsize);
    if (buf == NULL) {
        perror("malloc()");
        exit(1);
    }

    /* 从消息队列中接收消息。 */
    size = mq_receive(mqdes, buf, attr.mq_msgsize, &prio);
    if (size == -1) {
        perror("mq_receive()");
        exit(1);
    }

    /* 打印消息和其优先级。 */
    printf("msq: %s, prio: %d\n", buf, prio);

    free(buf);

    /* 重新注册mq_notify,以便下次可以出触发。 */
    sev.sigev_notify = SIGEV_SIGNAL;
    sev.sigev_signo = SIGUSR1;
    if (mq_notify(mqdes, &sev) == -1) {
        perror("mq_notify()");
        exit(1);
    }

    return;
}

int main(int argc, char *argv[])
{
    struct sigevent sev;

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

    /* 注册信号处理函数。 */
    if (signal(SIGUSR1, mq_notify_proc) == SIG_ERR) {
        perror("signal()");
        exit(1);
    }

    /* 打开消息队列,注意此队列需要先创建。 */
    mqdes = mq_open(argv[1], O_RDONLY);
    if (mqdes == -1) {
        perror("mq_open()");
        exit(1);
    }

    /* 注册mq_notify。 */
    sev.sigev_notify = SIGEV_SIGNAL;
    sev.sigev_signo = SIGUSR1;
    if (mq_notify(mqdes, &sev) == -1) {
        perror("mq_notify()");
        exit(1);
    }

    /* 主进程每秒打印一行x,等着从消息队列发来异步信号触发收消息。 */
    while (1) {
        printf("x\n");
        sleep(1);
    }
}

我们编译这个程序并执行:

[zorro@zorro-pc mqueue]$ ./notify_sig /mqtest
x
x
...

会一直打印x,等着队列变为非空,我们此时在别的终端给队列发送一个消息:

[zorro@zorro-pc mqueue]$ ./send hello 1

进程接收到信号,并且现实消息相关内容:

...
x
x
msq: hello, prio: 1
x
...

再发一个试试:

[zorro@zorro-pc mqueue]$ ./send zorro 3

显示:

...
x
msq: zorro, prio: 3
x
...

在mq_notify的man手册中,有一个触发线程进行异步处理的例子,我们在此就不再额外写一遍了,在此引用并注释一下,以方便大家理解:

[zorro@zorro-pc mqueue]$ cat example.c
#include <pthread.h>
#include <mqueue.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define handle_error(msg) \
    do { perror(msg); exit(EXIT_FAILURE); } while (0)

static void                     /* Thread start function */
tfunc(union sigval sv)
{
    /* 此函数在队列变为非空的时候会被触发执行 */

    struct mq_attr attr;
    ssize_t nr;
    void *buf;

    /* 上一个程序时将mqdes实现成了全局变量,而本例子中使用sival_ptr指针传递此变量的值 */
    mqd_t mqdes = *((mqd_t *) sv.sival_ptr);

    /* Determine max. msg size; allocate buffer to receive msg */

    if (mq_getattr(mqdes, &attr) == -1)
        handle_error("mq_getattr");
    buf = malloc(attr.mq_msgsize);
    if (buf == NULL)
        handle_error("malloc");

    /* 打印队列中相关消息信息 */
    nr = mq_receive(mqdes, buf, attr.mq_msgsize, NULL);
    if (nr == -1)
        handle_error("mq_receive");

    printf("Read %zd bytes from MQ\n", nr);
    free(buf);

    /* 本程序取到消息之后直接退出,不会循环处理。 */
    exit(EXIT_SUCCESS);         /* Terminate the process */
}

int
main(int argc, char *argv[])
{
    mqd_t mqdes;
    struct sigevent sev;

    if (argc != 2) {
        fprintf(stderr, "Usage: %s <mq-name>\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    mqdes = mq_open(argv[1], O_RDONLY);
    if (mqdes == (mqd_t) -1)
        handle_error("mq_open");

    /* 在此指定当异步事件来的时候以线程方式处理,
    触发的线程是:tfunc
    线程属性设置为:NULL
    需要给线程传递消息队列描述符mqdes,以便线程接收消息 */

    sev.sigev_notify = SIGEV_THREAD;
    sev.sigev_notify_function = tfunc;
    sev.sigev_notify_attributes = NULL;
    sev.sigev_value.sival_ptr = &mqdes;   /* Arg. to thread func. */
    if (mq_notify(mqdes, &sev) == -1)
        handle_error("mq_notify");

    pause();    /* Process will be terminated by thread function */
}

大家可以自行编译执行此程序进行测试。请注意mq_notify的行为:

  1. 一个消息队列智能通过mq_notify注册一个进程进行异步处理。
  2. 异步通知只会在消息队列从空变成非空的时候产生,其它队列的变动不会触发异步通知。
  3. 如果有其他进程使用mq_receive等待队列的消息时,消息到来不会触发已注册mq_notify的程序产生异步通知。队列的消息会递送给在使用mq_receive等待的进程。
  4. 一次mq_notify注册只会触发一次异步事件,此后如果队列再次由空变为非空也不会触发异步通知。如果需要一直可以触发,请处理异步通知之后再次注册mq_notify。
  5. 如果sevp指定为NULL,表示取消注册异步通知。

POSIX消息队列相对XSI消息队列的一大优势是,我们又一个类似文件描述符的mqd的描述符可以进行操作,所以很自然的我们就会联想到是否可以使用多路IO转接机制对消息队列进程处理?在Linux上,答案是肯定的,我们可以使用select、poll和epoll对队列描述符进行处理,我们在此仅使用epoll举个简单的例子:

[zorro@zorro-pc mqueue]$ cat recv_epoll.c
#include <fcntl.h>
#include <sys/stat.h>
#include <mqueue.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <sys/epoll.h>

#define MQNAME "/mqtest"
#define EPSIZE 10


int main()
{

    mqd_t mqd;
    int ret, epfd, val, count;
    char buf[BUFSIZ];
    struct mq_attr new, old;
    struct epoll_event ev, rev;

    mqd = mq_open(MQNAME, O_RDWR);
    if (mqd == -1) {
        perror("mq_open()");
        exit(1);
    }

    /* 因为有epoll帮我们等待描述符是否可读,所以对mqd的处理可以设置为非阻塞 */
    new.mq_flags = O_NONBLOCK;

    if (mq_setattr(mqd, &new, &old) == -1) {
        perror("mq_setattr()");
        exit(1);
    }

    epfd = epoll_create(EPSIZE);
    if (epfd < 0) {
        perror("epoll_create()");
        exit(1);
    }

    /* 关注描述符是否可读 */
    ev.events = EPOLLIN;
    ev.data.fd = mqd;

    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, mqd, &ev);
    if (ret < 0) {
        perror("epoll_ctl()");
        exit(1);
    }

    while (1) {
        ret = epoll_wait(epfd, &rev, EPSIZE, -1);
        if (ret < 0) {
            /* 如果被信号打断则继续epoll_wait */
            if (errno == EINTR) {
                continue;
            } else {
                perror("epoll_wait()");
                exit(1);
            }
        }

        /* 此处处理所有返回的描述符(虽然本例子中只有一个) */
        for (count=0;count<ret;count++) {
            ret = mq_receive(rev.data.fd, buf, BUFSIZ, &val);
            if (ret == -1) {
                if (errno == EAGAIN) {
                    break;
                }
                perror("mq_receive()");
                exit(1);
            }
            printf("msq: %s, prio: %d\n", buf, val);
        }

    }

    /* 恢复描述符的flag */
    if (mq_setattr(mqd, &old, NULL) == -1) {
        perror("mq_setattr()");
        exit(1);
    }

    ret = mq_close(mqd);
    if (ret == -1) {
        perror("mp_close()");
        exit(1);
    }
    exit(0);
}

这就是POSIX消息队列比XSI更有趣的地方,XSI的消息队列并未遵守“一切皆文件”的原则。当然,使用select和poll这里就不再举例了,有兴趣的可以自己实现一下作为练习。

以上例子中,我们也分别演示了如何使用mq_setattr和mq_getattr,此处我们应该知道,在所有可以显示的属性中,O_NONBLOCK是mq_setattr唯一可以更改的参数设置,其他参数对于这个方法都是只读的,不能修改。系统提供了其他手段可以对这些限制进行修改:

/proc/sys/fs/mqueue/msg_default:在mq_open的attr参数设置为NULL的时候,这个文件中的数字限定了mq_maxmsg的值,就是队列的消息个数限制。默认为10个,当消息数达到上限之后,再使用mq_send发送消息会阻塞。

/proc/sys/fs/mqueue/msg_max:可以通过mq_open的attr参数设定的mq_maxmsg的数字上限。这个值默认也是10。

/proc/sys/fs/mqueue/msgsize_default:在mq_open的attr参数设置为NULL的时候,这个文件中的数字限定了mq_msgsize的值,就是队列的字节数数限制。

/proc/sys/fs/mqueue/msgsize_max:可以通过mq_open的attr参数设定的mq_msgsize的数字上限。

/proc/sys/fs/mqueue/queues_max:系统可以创建的消息队列个数上限。

最后

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

请任意打赏,多谢多谢!

收钱


大家好,我是Zorro!

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

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

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

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

公众号二维码:

Zorro] icon


 

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. 什么是内建命令?为什么要有内建命令?
  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)?如果交代事情的人没告诉我们这些,那么我们就要问清楚,以保证我们能把事情做对!如果交代事情的人描述事情的时候使用了一些名词,那么我更加建议大家去问清楚这些名词要表达的意思是不是你理解的意思?即使这些名词好像是大家都知道的。否则辞典为什么这么有用?

于是我们再回到同事跟我讨论问题(我试图理解他的需求)的场景,还有人会认为我咬文嚼字是多余的么?

关于情商

中午根同事一起吃饭,同事因为看了我的博客上的《你爱我,与我何干?》而感慨说:唉,你的情商也太低了。怎么能跟长辈这么说该你屁事这样的话?碰巧,当我跟伯父说这话的时候,父亲就提醒过我,也说我这样说话情商低,在外容易得罪人。其实几乎每次我放假回家跟父母在一起的时候,他们都会不断的提醒我,说我们家人都是直脾气,不懂得圆滑处事,他们年轻的是候就吃过很多亏,所以希望我能吸取教训,不要重蹈覆辙,做人要圆滑。那么我理解的这个“圆滑”,其实就是所谓的高情商,也就是说,我在他们眼里也是低情商。曾经那个不太懂事的我也一直以来我也都以为自己知道自己是低情商、不圆滑的人,并且曾以此为个性。直到有一天,我认真思考了一下这个问题:我的情商为什么低?于是随之而来的另一个问题就是:到底什么是情商?于是我就找了很多资料,其中知乎上的几个帖子让我茅塞顿开:
https://www.zhihu.com/question/21112415
https://www.zhihu.com/question/20543245

不知道有多少人思考过这个问题:什么是情商?

情商的概念属于心理学范畴我们这里不去细节讨论定义,如果只从大家方便理解的方面考虑,情商主要是指以下两个能力:

1、控制自己情绪的能力。
2、控制别人情绪的能力。

人类是一个社会动物,我们需要协作,那么必然需要交流,而所谓的情商也就表现在这个过程中。以我为例,我表达自己的观点时,说的是:关你屁事!我为什么要这样说?因为这个语境和用词可以最直观的表达出我对此事的情绪和看法,这是成本最低的表达方式。当然,当时的上下文语境也并不是跟谁吵架,而仅仅是单纯的表达态度。我本身说这句话并不带情绪,选词也是深思熟虑过的。并且我也做好了长辈会发作的心里预期。而长辈此时确实因为我的用词和语气而发作,认为我是不礼貌的。当然之后我们没有不欢而散,而是我陪上笑脸赶紧道歉,最后长辈也明白了我要表达的意思。在这个过程中,究竟谁情商更高呢?

如果仅从自我的情绪控制上看,我毫不谦虚的说,本人情商很高,我可以在整个过程中控制好自己的情绪。该表达观点的时候表达,该哄长辈开心的时候就哄。而相反是长辈情绪控制相对较差,因为自己辈份大而不能接受平等的沟通,于是就以愤怒来对待,这是情商差的表现。当然,我这位大伯毕竟生活阅历丰富,还是哄的好的,在我的道歉和劝说下,他还是能够控制住自己情绪的,还是有一定情商的。在相同的场景下,很多人是连哄也哄不好的,情绪自控能力当然更差。

通过这个例子来看,我发现其实并不是我的情商有多低,而往往是别人控制自己情绪的能力太差。这些人因为一些原因不能跟你平等沟通,当你直接表达自己意见的时候,他们就可能受不了。这仍然与我国的传统文化密不可分,我们强调三纲五常,君君臣臣、父父子子。我们的文化中没有平等,所有人都应在在自己的位置上,面对别人的时候要么是跪着唯唯诺诺,要么是站着颐指气使。我们的传统文化中根本没有情商的概念,所以对于中华文化来说,所谓的高情商、圆滑、老油条其实就是装孙子。就是我不管你怎么样,我先跪下行不?你总不能说我情商低了吧?

请别理解成我是给自己的不礼貌找借口,起码的礼貌我们当然要讲,起码的尊老爱幼我们当然要坚坚持。尊敬领导,尊重同事这些基本的道德必须有。我很清楚我根长辈说关你屁事是不礼貌的,我道歉并也认为自己是错的。长辈当然有权利生气,但是不要混淆概念,当我们讨论情商的时候,就是指我们再某些情况下能否控制自己的情绪。

这只是情商的一方面,是从管理自己情绪来说的。情商还有管理别人情绪能力的要求。说白了就是控制别人情绪的能力。一般情况我们都是希望让别人高兴的,因为绝大多数场景都是我们有求于别人,让人开心了才能获得利益。但是不排除让人生气也可能获得利益的情况,比如:三气周瑜。你会觉得诸葛亮在气周瑜的时候是情商低么?在这个例子中,诸葛亮恰恰是情商管理大师。所以说,控制别人的情绪最重要的是同理心,就是很清楚别人什么情况下会生气,什么情况下会开心,并以此决定自己的行为,来达到目的。从这一点说,当你在说某一句话之前,就知道对方会是什么反应的时候,你的情商实际上就是高人一筹了。但是也会有很多人因为对自己的情绪掌握不好,而在明知道对方会不开心的情况下,还非要这样做,导致一个损人不利己的结果,这必然是低情商的表现。所以,我觉得提高情商的核心方法仍然是修炼对自己情绪的控制。

在管理学的著作中,情商也更加强调控制自己情绪,因为别人的情绪虽然可以被引导,但是想要在管理上真正可控是不可能的,毕竟那是别人的情绪。我认为,管理学强调对自己的情绪的控制是很好的思路,因为在一个合作场景中,如果每个人都可以提升自己情绪的控制能力,那么其他人就可以减少在控制别人情绪方面所付出的成本,而使沟通变得更直接和顺畅。这也就是现代组织形式和传统体制化的组织形式的人际关系的差别:当组织不存在竞争压力的时候,其体制就会腐化为强调伦常道德,官本位等体制化方式运作,因为资源都集中在上层,你只有让上层的人舒服了,你才能分到资源,你要么跪着,要么站着。有人形容这样的体制就像是一群猴子爬树,上面的猴子看下面,一群笑脸。下面的猴子看上面,全是屁股。其实全都只是屁股还好,有时你还要接着屁和屎。当组织是在自由市场竞争,需要通过不断提升服务能力和降低成本而盈利而生存的时候,当然内部沟通成本越少越好,这时大家需要把精力都用在做事上,所以需要高情商的人合作,大家都能控制自己的情绪,减少不必要的沟通成本。

这也许也就是乔布斯说的那句话的原因:一流人才的自尊心,不需要你呵护。

你爱我,与我何干?

春节期间休息在家陪父母,今天又是姑姑生日,于是按照传统要去她家聚会,这几乎是每年的惯例。饭桌上酒过三巡菜过五味后,大伯表达了一下他对我们这一代人的看法,大概的意思就是说我们这一代人对人际关系的处理比较差劲,比如跟亲戚之间有隔阂,堂兄弟之间也不爱多联系;比如对待爱情也不成熟,处理不好夫妻男女朋友之间的关系。总之,在他眼中,我们这一代人就是比较矫情。由于之前他也一直好奇我为什么总不爱说话,所以我决定在这一话题上代表我们这“一代人”表示一下我的看法。我不知道我的答案是不是能代表我们这一代人,我说的是:“关你屁事?”

结果呢?自然是大伯大怒,当场怒斥我不礼貌。我呢?当然是立即承认错误,之后继续不说话,然后听大伯和我父亲以及其它亲戚结成同盟批评我:“不养儿不知父母恩”等等等等大家可以自行补脑的话。

春节对于我来说,从小到大就是这样的纠结的一个节。在这样的喜庆日子里,亲友团聚的时刻却总是让我感觉到无地自处,全身都不适应。每年,都会经历这样一轮节日的洗礼,这种洗礼的结果就是,你总是在不断的反省是不是自己什么地方做错了?需要改正什么地方?因为按照他们的说法,由于我们的各种不“正常”,我们早该在这个社会上被淘汰掉了,我们早该没有任何生存能力养活自己而回家继续啃老了。不过我是如此幸运,社会是如此宽容,我还能养的活自己。但是事实是这样么,该反省的真的是我们么?

看看日历恰逢今天又是情人节,所以觉得很适合讨论一个话题,就是爱情。其实爱情是我们国家传统文化中的最核心的内容,儒家倡导的最核心的思想就是仁。仁的意思就是“仁者爱人”,就是说白了就是要爱别人的意思。仁字也设计的很形象,二人世界。于是我们的传统文化中所倡导的很多关系都是这种二人世界的演化版,比如:孝、忠、悌。仔细分析一下,中国人所有的关系似乎都只是二人世界,而自己的独立生活空间往往是没有的。就比如什么是孝?顺者为孝。就是一切按照老人的想法做就是顺,于是就做到了孝。就比如我,由于怕说实话伤害了长辈的感情而不孝,所以就不说话。于是人家觉得你是不是有心事?有什么难处?让人操心了,我就又是不孝。我说了实话,于是就是不礼貌,不注意讲话,又是不孝。当然我也可以选择说假话,赞成大伯的说法,说我们这代人就是矫情,就是处理不好各种关系,那我是不是也太让他们不省心了?先不说我自己是不是能接受这种自我摧残,即便说了承认了自己不省心这样的话骗他们是不是也是不孝?

分析这样的问题,我有一个笨办法,就是看看我能选择的所有情况是不是都是错误答案,如果是,那就说明是题出错了。这明显就是题出错了。那么究竟错在哪里?我爱你,错了么?

对于爱,曾经最喜欢张爱玲的一句话:我爱你,与你无关。曾经我认为这是最健康的爱的方式。但是我从没有仔细想过这句话也可以用在亲情的爱上。中国人对于爱,有着太多的憧憬。这种憧憬实际上更多的是对回报的憧憬。中国人对爱的隐含意思是:既然我这么爱你,你就应该以我觉得合适的方式和量级来回报给我。如果不是这样,那就是你不爱我。

我想这就是这个社会中绝大多数人对爱的逻辑。于是才有了逼婚,才有了结婚后逼生孩子。有多少人连自己的结婚日期都不是自己定的,而是按照父母的时间表由父母进行安排,搞得自己都搞不清楚是不是自己结婚?甚至有的父母连你考什么大学,报什么专业都帮你确定好了的?更有甚者,父母从小就开始培养孩子的某一个技能,以便让孩子长大以后就从事相关工作。在他们眼里,孩子已经不是孩子了,只是他们实现自己梦想的工具而已。他们以爱的名义不同程度的侵入了孩子的私人空间,并美其名曰,我爱你!搞得你无论怎么选都是错的,因为你不可以有自己的选择。这不是你的人生,这是他们的人生。

说到这里,有些不近人情了。父母可能真的并不觉得这是子女的私事,他们也真的觉得这是爱你。并且会觉得我们不养儿不知父母恩,等你养了孩子你就知道了。我确实还没养孩子,我也确实可能不理解他们的心情,但是关于这一点,我想我更赞同胡适的看法,在胡适答汪长禄的信中他这样写道:

““父母于子无恩”的话,从王充、孔融以来,也很久了。从前有人说我曾提倡这话,我实在不能承认。直到今年我自己生了一个儿子,我才想到这个问题上去。我想这个孩子自己并不曾自由主张要生在我家,我们做父母的不曾得他的同意,就糊里糊涂的给了他一条生命。况且我们也并不曾有意送给他这条生命。我们既无意,如何能居功?如何能自以为有恩于他?他既无意求生,我们生了他,我们对他只有抱歉,更不能“市恩”了。我们糊里糊涂的替社会上添了一个人,这个人将来一生的苦乐祸福,这个人将来在社会上的功罪,我们应该负一部分的责任。说得偏激一点,我们生一个儿子,就好比替他种下了祸根,又替社会种下了祸根。他也许养成坏习惯,做一个短命浪子;他也许更堕落下去,做一个军阀派的走狗。所以我们“教他养他”,只是我们自己减轻罪过的法子,只是我们种下祸根之后自己补过弥缝的法子。这可以说是恩典吗?

所说的,是从做父母的一方面设想的,是从我下人对于我自己的儿子设想的,所以我的题目是“我的儿子”。我的意思是要我这个儿子晓得我对他只有抱歉,决不居功,决不市恩。至于我的儿子将来怎样待我,那是他自己的事。我决不期望他报答我的恩,因为我已宣言无恩于他。”

等我有了孩子之后,我将要以这样的心情教他养他。也做一个像张爱玲、胡适那样有着自由主义爱情观的人。

也许我们这一代的人的责任就是,承接好上一代对我们“无微不至”的爱,并把自由主义爱情观通过行动传递给下一代,让他们有独立的人格和生活空间。

那么今天,请允许我说:你爱我,与我何干?