注意:许多现代shell,如KornShell 93r+、Bash 4.1α+和Zsh 4.3.4+,提供了本地解决方案:exec {var}< filename
。这将一个未使用的描述符(大于9)分配给var
,可以使用<&$var
或{var}<&-
。有关详细信息,请参见this answer。Ash和Dash都不支持此功能。
以下解决方案避免使用特定于操作系统的功能甚至其他二进制文件;相反,它仅使用由 shell 直接提供的工具,以实现最大的可移植性。它旨在与 POSIX.1-2017 Shell & Utilities 兼容,并且适用于 ash
(BusyBox v1.16.1)、bash
(v3.00)、dash
(v0.5.8)、ksh
(93u)和 zsh
(v5.9)。
unused_fd() (
FD=${1:-3}
MAX=$(ulimit -n)
while [ $FD -lt $MAX ]
do
if ! ( : <&$FD ) 2>&-
then
printf %d $FD
[ "$(eval "echo $FD<&-")" ] && return 8
return 0
fi
FD=$(( FD + 1 ))
done
return 24
)
如果找到未使用的文件描述符,则此函数会在标准输出上打印它;否则,它不会打印任何内容。如果可以在 n<
、n>
等中使用已打印的文件描述符,则退出零;否则退出非零。
用法
FILENAME=foo
( # Spawn subshell (unnecessary on Bash or Zsh)
FD=$(unused_fd) || exit # Find an available descriptor
eval "exec $FD<\"\$FILENAME\"" # Open descriptor, if one was available
… # Use descriptor, if one was opened
) # Descriptor closes with subshell termination
注意:虽然Bash支持将重定向到任何文件描述符,但大多数shell将重定向限制在文件描述符0-9之间。如果需要更多的描述符,并且shell支持它,请使用上述exec {var}< filename
、exec {var}> filename
等方法,绕过对unused_fd
(和eval
)的需求。
如果找不到描述符,则$FD
可能为空,并且exec $FD<…
可能会覆盖标准输入。通过检查unused_fd
的退出并跳过exec
,以防止出现这种情况。
当使用变量时,此eval
的用法(请注意"
和\$
)支持重定向带有空格或任何特殊字符(例如'
、"
、\
)的文件路径。
POSIX、exec
和子shell
根据
§ 2.8.1,使用特殊内置工具(如
exec
)时,重定向错误(例如
文件未找到)将导致shell立即退出(或停止处理)。为避免整个脚本在出现错误时停止运行,请使用文件描述符将部分内容包装在子shell中;子shell将在出现错误时退出,剩余的脚本可以继续运行。所有与描述符相关的操作必须在打开它的子shell中进行。
注意:一些shell(如Bash和Zsh)通过提供错误而不终止来破坏POSIX兼容性;在这些shell上,检查
exec
的退出是否失败是值得的。然而,缺乏终止也意味着如果可移植性不是问题,则可以省略子shell包装。
设计细节
$(open_r)
的限制
FD=$(open_r "$FILENAME")
不起作用,因为$()
会fork出一个命令替换子shell;打开新文件描述符的是子shell而不是赋值给FD
的shell。由于子shell无法修改父shell的环境,所以打开的文件描述符无法在子shell之外使用[0]。
如果避免使用命令替换子shell,就像this answer中所做的那样,可以创建一个类似于open_r
的函数。
开发 unused_fd
当文件描述符3未打开时,执行$COMMAND <&3
将导致shell本身发出类似于sh: 3: Bad file descriptor
的错误消息。此外,当这种情况发生时,$COMMAND
从未启动,并且shell将$?
设置为非零值。因此,如果$COMMAND
运行,则我们知道文件描述符3已经打开。
为确保非零退出是来自于 shell(而不是命令),运行一个永远不会非零退出的命令:内置的
:
。使用
: <&3
,如果文件描述符 3 无法复制且未使用的描述符是复制失败的主要原因,则 shell 将以非零退出(理想情况下只有这种情况)[1]。
遗憾的是,对
: <&3
的重定向(复制)错误会导致符合 POSIX 标准的 shell 立即终止命令处理,从而杀死正在运行的脚本。要使 shell 脚本继续运行,请在可以死亡的子 shell 中执行检查:
( : <&3 )
。
为了消除 shell 的错误信息,请使用
2>&-
关闭子 shell 上的标准错误。关闭描述符可与没有
/dev/null
的系统兼容 [2,3]。
为了避免覆盖 shell 的变量,请使用子 shell(即
()
)包装复合列表,而不是使用当前进程环境(即
{;}
)作为函数定义[4]。
除了Bash之外,大多数shell只将n<
等解析为单个标记,当n为0-9时。较大的数字,如10
,则被解析为两个单独的标记:10
和<
。使用echo
检查这一点,如果10<&-
被解析为一个单元,则除换行符外不会打印任何内容,但如果10<&-
被解析为10
和<&-
,则会打印出10
;通过退出值[2]告知调用者。
避免数据流损坏
在符合POSIX标准的系统上,当文件描述符被复制时,原始描述符和复制的描述符引用相同的文件描述符,对一个进行更改会影响其他描述符;参见
dup2(2)。因此,重要的是确保由
unused_fd
运行的任何命令都不会写入或读取正在测试可用性的文件描述符。由于内置的
:
不执行命令,所以不必担心
read
(2)或
write
(2)调用会导致损坏。
如果内置的
:
被取代,就像
fork bomb一样臭名昭著地被取代,那么这个要求可能不再得到满足。然而,由于
:
通常在脚本中使用,并期望不执行命令,对
:
的任何修改都将产生远远超出
unused_fd
函数范围的影响。
只写文件描述符
如果创建了一个只写文件描述符(例如exec 3>/path/to/file
),即使它被复制,也无法从该描述符读取。那么,: <&3
如何不立即出错?
在POSIX系统上,shell通常将n<&m
和n>&m
视为相同,仅调用dup2(m,n)
而不评估方向是否合理。关闭文件描述符时也是如此。
通过这种设置,只有当某些东西尝试从只写文件描述符中读取时,才会发生错误(在调用read
(2)的命令中)。由于内置的:
不读取(或写入)任何内容,因此避免了此错误。
脚注
[0] 一个进程可以使用特殊的连接器(例如UNIX域套接字或D-Bus)将文件描述符发送给另一个进程(例如其父进程)。这需要shell接收该值,但在$()子shell中无法发生,因为接收到的描述符会在该子shell终止时关闭。因此,在
FD=$(open_r …)
之前或之后必须在shell中进行一些逻辑处理。这样的解决方案可能比所提供的USAGE更冗长。
[1] 作为一个特殊的内置功能,:
不需要调用 fork
(2)、execve
(2) 或任何其他可能在极端情况下返回意外错误的系统调用。这理想情况下将只留下 dup2
(2) 系统调用作为可能发生错误的唯一来源,但是个别实现可能会有所不同。无论如何,在正常操作条件下,唯一预期的错误将来自于 dup2
,并且该错误将是 EBADF
,或者如果 shell 没有编程重试,则来自于竞争条件(即 EINTR
或 EBUSY
)。
[2] 尝试关闭已经关闭的文件描述符将不会导致错误(根据 POSIX.1-2017 Shell & Utilities § 2.7.6)。
[3] 即使子shell在write
(2)期间由于标准错误的关闭而打印错误消息时出现错误而死亡,这只会发生如果子shell本来就要退出非零。因此,关闭应该在功能上等同于重定向到/dev/null
。
[4] local
的行为未定义,根据POSIX.1-2017 Shell & Utilities § 2.9.1:
(1.) b. 如果命令名称与以下表中列出的实用程序的名称匹配,则结果是未指定的。… local …
( flock $FD; echo got the lock; ) {FD}> mylock
。 - not-a-userbash:exec:{FD}:未找到
。 - KingPong