set,env,export,source,exec傻傻分不清楚?

你是否被下面的几个问题困扰过,甚至至今无法真正理解?

  1. 什么是export,什么时候用export,为什么有时用了export还要source
  2. 为什么用env来设置环境变量,不用export,有什么好处?
  3. sourceexec有什么区别?

本文试图通过普及unix进程、环境变量等概念,让读者真真理解这些shell命令的本质,知道这些命令的使用场合。

set,env,export,source,exec傻傻分不清楚?

首先,先对这些命令做一个解释,如果读者能完全理解,那么本文也许对你帮助不大。

  • set设置了当前shell进程的本地变量,本地变量只在当前shell的进程内有效,不会被子进程继承和传递。
  • env仅为将要执行的子进程设置环境变量
  • export将一个shell本地变量提升为当前shell进程的环境变量,从而被子进程自动继承,但是export的变量无法改变父进程的环境变量。
  • source运行脚本的时候,不会启用一个新的shell进程,而是在当前shell进程环境中运行脚本。
  • exec运行脚本或命令的时候,不会启用一个新的shell进程,并且exec后续的脚本内容不会得到执行,即当前shell进程结束了。

在这些表述中,反复提到进程环境变量的概念。如果希望深入理解其中的含义,还必须理解进程的相关概念。

进程和环境变量

进程是一个程序执行的上下文集合,这个集合包括程序代码、数据段、堆栈、环境变量、内核标识进程的数据结构等。一个进程可以生成另一个进程,生成的进程称为子进程,那么相应的就有父进程,所谓子子孙孙无穷尽也。子进程父进程处会继承一些遗传因素,其中就包括本文的主题环境变量。环境变量是一组特殊的字符型变量,由于具有继承性质,环境变量也经常用于父子进程传递参数用,这一点在shell编程中尤为突出。

fork和exec

在unix系统中进程通过依次调用fork()exec()系统调用来实现创建一个子进程。
fork其实就是克隆,为什么github复刻别人的项目叫fork?就是这么来的,所谓“克隆”,就是在内存中将当前进程的所有内存镜像复制一份,所有东西都一样,只修改新进程的进程号(PID)。有点类似细胞分裂,细胞分裂后生成的细胞具有与原细胞完全相同的遗传因素。因为fork()会复制整个进程,包括进程运行到哪句代码,这意味着新的进程会继续执行fork()后面的代码,父进程也会运行fork()后面的代码,从fork()开始父子进程才分道扬镳。如果fork返回>0,那么说明在父进程中,如果fork返回==0,说明在子进程中:

pid = fork();
if(pid == 0) {
  //子进程中
} else if(pid > 0) {
  //父进程
}

精确的说exec是一组函数的统称,并且exec的准确定义是,用磁盘上的一个新的程序替换当前的进程的正文段、数据段、堆栈段。所以exec并不产生新的进程,而是替换。如此一来进程将从新代码的main开始执行,相当于另外运行了一个完全不同的程序,但保留了原来环境变量。

依据本文的主题,可以把exec函数分为两类,一类是可以设置并传递新环境变量的,一类是不能传递新环境变量的,只能继承原环境变量的。换句话说,在运行新的程序时,是有机会改变新程序的环境变量的,而不只是继承。如下面这个变种,可以通过envp参数设置环境变量

int execve(const char * filename,char * const argv[ ],char * const envp[ ]);

作为父进程而言,可以通过waitpid()函数等待子进程退出,并获得退出状态。

set,env,export,source,exec傻傻分不清楚?

进程可通过setenvputenv更改自己的环境变量,但环境变量的继承只能单向,即从父进程继承给fork出来的子进程。子进程即使修改了自己的环境变量也无法动摇到父进程的环境变量。

shell

shell并没有什么特殊,也是一个进程,当我们在命令行中敲入一个命令,并且按下Enter后,shell这个进程会通过fork和exec为我们创建一个子进程(存在一小部分命令不需要启动子进程,称为build-in命令),并且等待(waitpid)这个子进程完成退出。那么进程的内存镜像显然就包含本文的主题环境变量。比如,如果我们在shell命令行中执行ls -al,shell实际执行如下伪代码:

pid = fork();
if(pid == 0) {
  //子进程中,调用exec
  exec("ls -al");
} else if(pid > 0) {
  //父进程中,waitpid等待子进程退出
  waitpid(pid);
}

上面讨论了shell执行命令的情况,如果在命令行中执行一个shell脚本呢?默认情况下,shell进程会创建一个sub-shell子进程来执行这个shell脚本,并且等待这个子进程执行结束。

最后,再来审视一下本文的主题。首先set,source,export都是shell的build-in命令,命令本身不会创建新进程。

set其实跟进程创建无关,也跟环境变量无关,它只是当前shell进程内部维护的变量(本地变量),用于变量的引用和展开,不能遗传和继承。

但shell的export命令可以通过调用putenv将一个本地变量提升为当前shell的环境变量。但是,记住环境变量的继承只是单向的,sub-shellexport的变量在父shell中是看不到的。有什么办法可以让一个脚本中的export印象到父进程的环境变量呢?

答案是使用source执行脚本,source的用法如下:

source ./test.sh

如果用source执行脚本,意味着fork和exec不会被调用,当前shell直接对test.sh解释执行。这样的话,如果此时test.sh中有export(即putenv),那么将会改变当前shell的环境变量。

export如此好用,但是问题是它几乎会影响到其后的所有命令,有没有办法可以在运行某个命令时,临时启用某个环境变量,而不影响后面的命令呢?

答案是使用envenv的用法如下:

env GOTRACEBACK=crash ./test.sh
env不是shell的build-in命令,所以shell执行env的时候还是需要创建子进程的

env的作用从本质上说,相当于shell先fork,然后在子进程中运行env,子进程env调用execve运行test.sh时,多传了一个GOTRACEBACK=crash的环境变量(上文提到过execve是可以改变默认的继承行为的),这样test.sh可以看到这个GOTRACEBACK环境变量,但由于没有调用putenv改变父shell的环境变量,所以后续启动的进程并不继承GOTRACEBACK

exec意味着不调用fork,而是直接调用exec执行!这意味着当前shell的代码执行到exec后,代码被替换成了exec要执行的程序,自然地,后续的shell脚本不会得到执行,因为shell本身都被替换掉了。

set,env,export,source,exec傻傻分不清楚?

上图的env实际并不准确,因为env不是build-in命令,读者可自行脑补

嗯,光是从理论去理解,或许没那么好消化,不如动手“实作+思考”来的印象深刻哦。

问题一:写两个简单的script,分别命名为1.sh及2.sh:

1.sh

#!/bin/bash
A=B
echo "PID for 1.sh before exec/source/fork:$$"
export A
echo "1.sh: \$A is $A"
case $1 in
    exec)
        echo "using exec…"
        exec ./2.sh;;
    source)
        echo "using source…"
        ../2.sh;;
    *)
        echo "using fork by default…"
        ./2.sh;;
esac
echo "PID for 1.sh after exec/source/fork:$$"
echo "1.sh: \$A is $A"

2.sh

#!/bin/bash
echo "PID for 2.sh: $$"
echo "2.sh get \$A=$A from 1.sh"
A=C
export A
echo "2.sh: \$A is $A"

然后,分别跑如下参数来观察结果:

$ ./1.sh fork
$ ./1.sh source
$ ./1.sh exec

问题二:用env设置环境变量后,运行的脚本中又调用了其他脚本,这个环境变量还会继承下去吗?

相关推荐