在Bash中,如何查找未使用的最低编号文件描述符?

56

在Bash脚本中,是否可以打开“尚未使用的最低编号文件描述符”的文件?

我已经查找了如何做到这一点,但似乎Bash始终要求您指定数字,例如:

exec 3< /path/to/a/file    # Open file for reading on file descriptor 3.

相反,我想要能够做类似于以下的事情

my_file_descriptor=$(open_r /path/to/a/file)

使用该代码会打开文件并将其分配给尚未使用的最低编号的文件描述符,并将该编号赋值给变量'my_file_descriptor'。

7个回答

87

我知道这个线程很老了,但相信最佳答案还没有出现,对像我这样在寻找解决方案的其他人也有用。

Bash和Zsh内置了查找未使用文件描述符的方法,无需编写脚本。(我发现dash没有这样的东西,因此上面的答案仍然可能有用。)

注意:这将查找大于10的最低未使用文件描述符,而不是最低的整体。

$ man bash /^REDIRECTION (paragraph 2)
$ man zshmisc /^OPENING FILE DESCRIPTORS

例子可以在bsh和zsh中使用。

打开一个未使用的文件描述符,并将其编号赋值给$FD:

$ exec {FD}>test.txt
$ echo line 1 >&$FD
$ echo line 2 >&$FD
$ cat test.txt
line 1
line 2
$ echo $FD
10  # this number will vary

完成后关闭文件描述符:

$ exec {FD}>&-
以下显示文件描述符现已关闭:
$ echo line 3 >&$FD
bash: $FD: Bad file descriptor
zsh: 10: bad file descriptor

16
非常感谢您不让这个帖子的年代阻止您发布回复!事实上,您的答案是(此帖子中)我在18个月前发布问题时真正寻找的答案;其他答案都只是“解决办法”。然而,在您的解决方案中使用的{FD}>功能在Bash 4.0或更早版本中不受支持。它是在Bash 4.1-alpha中引入的,因此其他答案中提供的解决办法可能对那些被困在Bash 4.0或更早版本中的人仍然有价值。 - Mikko Östlund
3
好的!这也适用于典型的“flock”场景:( flock $FD; echo got the lock; ) {FD}> mylock - not-a-user
我一直在使用mkfifo来解决这个问题。但实际上,这并不是一个完美的解决方案,因为FIFOs没有“存储”功能。所以非常感谢您提供的帮助! - Bladt
不幸的是,在Bash 3中无法工作。bash:exec:{FD}:未找到 - KingPong
你好,如果没有可用的文件描述符会发生什么? - Pedro A

7

如果是在Linux上,您可以始终读取/proc/self/fd/目录以查找已使用的文件描述符。


谢谢你指引我,Basile!我随后使用了它并且写了自己的答案回答了这个问题。 - Mikko Östlund
1
是的,如果你不在Linux上,你可能可以使用/dev/fd。请参考我的答案。 - KingPong

5

我需要支持Mac上的bash v3,也需要支持Linux上的bash v4,但其他解决方案要求使用bash v4或者Linux系统,因此我想出了一个既适用于两种情况的解决方案,使用/dev/fd

find_unused_fd() {
  local max_fd=$(ulimit -n)
  local used_fds=" $(/bin/ls -1 /dev/fd | sed 's/.*\///' | tr '\012\015' '  ') "
  local i=0
  while [[ $i -lt $max_fd ]]; do
    if [[ ! $used_fds =~ " $i " ]]; then
      echo "$i"
      break
    fi
    (( i = i + 1 ))
  done
}

比如要复制标准输出,可以这样做:

newfd=$(find_unused_fd)
eval "exec $newfd>&1"

2
我喜欢这个想法。我把它压缩到了一行代码。/bin/ls -1 /dev/fd | sed 's/.*\///' | sort -n | awk 'n<$1{exit}{n=$1+1}END{print n}' - Bruno Bronosky
非常好。从中我学到(并确认)"/dev/fd" 对于每个不同的 shell 实例都是自定义的。@"Bruno Bronsky" 为什么要用 'sed'? - IAM_AL_X

5

我修改了我的原始答案,现在为原帖提供了一行解决方案。
以下函数可以存放在全局文件或来源脚本中(例如 ~/.bashrc):

# Some error code mappings from errno.h
readonly EINVAL=22   # Invalid argument
readonly EMFILE=24   # Too many open files

# Finds the lowest available file descriptor, opens the specified file with the descriptor
# and sets the specified variable's value to the file descriptor.  If no file descriptors
# are available the variable will receive the value -1 and the function will return EMFILE.
#
# Arguments:
#   The file to open (must exist for read operations)
#   The mode to use for opening the file (i.e. 'read', 'overwrite', 'append', 'rw'; default: 'read')
#   The global variable to set with the file descriptor (must be a valid variable name)
function openNextFd {
    if [ $# -lt 1 ]; then
        echo "${FUNCNAME[0]} requires a path to the file you wish to open" >&2
        return $EINVAL
    fi

    local file="$1"
    local mode="$2"
    local var="$3"

    # Validate the file path and accessibility
    if [[ "${mode:='read'}" == 'read' ]]; then
        if ! [ -r "$file" ]; then
            echo "\"$file\" does not exist; cannot open it for read access" >&2
            return $EINVAL
        fi
    elif [[ !(-w "$file") && ((-e "$file") || !(-d $(dirname "$file"))) ]]; then
        echo "Either \"$file\" is not writable (and exists) or the path is invalid" >&2
        return $EINVAL
    fi

    # Translate mode into its redirector (this layer of indirection prevents executing arbitrary code in the eval below)
    case "$mode" in
        'read')
            mode='<'
            ;;
        'overwrite')
            mode='>'
            ;;
        'append')
            mode='>>'
            ;;
        'rw')
            mode='<>'
            ;;
        *)
            echo "${FUNCNAME[0]} does not support the specified file access mode \"$mode\"" >&2
            return $EINVAL
            ;;
    esac

    # Validate the variable name
    if ! [[ "$var" =~ [a-zA-Z_][a-zA-Z0-9_]* ]]; then
        echo "Invalid variable name \"$var\" passed to ${FUNCNAME[0]}" >&2
        return $EINVAL
    fi

    # we'll start with 3 since 0..2 are mapped to standard in, out, and error respectively
    local fd=3
    # we'll get the upperbound from bash's ulimit
    local fd_MAX=$(ulimit -n)
    while [[ $fd -le $fd_MAX && -e /proc/$$/fd/$fd ]]; do
        ((++fd))
    done

    if [ $fd -gt $fd_MAX ]; then
        echo "Could not find available file descriptor" >&2
        $fd=-1
        success=$EMFILE
    else
        eval "exec ${fd}${mode} \"$file\""
        local success=$?
        if ! [ $success ]; then
            echo "Could not open \"$file\" in \"$mode\" mode; error: $success" >&2
            fd=-1
        fi
    fi

    eval "$var=$fd"
    return $success;
}

以下是使用上述函数打开文件进行输入和输出的示例代码:
openNextFd "path/to/some/file" "read" "inputfile"
# opens 'path/to/some/file' for read access and stores
# the descriptor in 'inputfile'

openNextFd "path/to/other/file" "overwrite" "log"
# truncates 'path/to/other/file', opens it in write mode, and
# stores the descriptor in 'log'

接下来,您可以像往常一样使用前面的描述符来读写数据:

read -u $inputFile data
echo "input file contains data \"$data\"" >&$log

感谢Coren在进行这次令人印象深刻的彻底调查中所花费的时间和精力!作为问题的所有者,我已将您的答案标记为“已接受”。不过,有趣的是,这个问题竟然如此难以解决! - Mikko Östlund
Coren,我在这里添加了一条评论,希望你能收到通知并来这里。我想你会有兴趣看到Weldabar的解决方案,如果使用Bash 4.1-alpha或更高版本,则绝对是正确的方法。(Weldabar的解决方案不受Bash 4.0或更早版本的支持。) - Mikko Östlund

2
在Basile Starynkevitch于2011年11月29日回答这个问题时,他写道:
如果是在Linux上,您可以始终读取/proc/self/fd/目录以查找已使用的文件描述符。
通过阅读fd目录进行了几次实验后,我得出了以下代码,作为与我寻找的“最接近的匹配”。我实际上正在寻找一个bash一行命令,例如
my_file_descriptor=$(open_r /path/to/a/file)

该代码将找到最低的未使用文件描述符,并且在其中打开文件,并将其分配给变量。如下所示,在引入函数"lowest_unused_fd"后,我至少可以获得一个“两行式”(FD=$(lowest_unused_fd) 然后 eval "exec $FD<$FILENAME")来完成任务。我无法编写像上面那个虚构的"open_r"函数一样工作的函数。如果有人知道如何做到这一点,请站出来!相反,我不得不将任务分成两个步骤:一个步骤是查找未使用的文件描述符,另一个步骤是在其上打开文件。还要注意,为了能够将查找步骤放在函数("lowest_unused_fd")中,并将其stdout分配给FD,我必须使用"/proc/$$/fd"而不是Basile Starynkevitch建议的"/proc/self/fd",因为bash为执行函数生成子shell。

#!/bin/bash

lowest_unused_fd () {
    local FD=0
    while [ -e /proc/$$/fd/$FD ]; do
        FD=$((FD+1))
    done
    echo $FD
}

FILENAME="/path/to/file"

#  Find the lowest, unused file descriptor
#+ and assign it to FD.
FD=$(lowest_unused_fd)

# Open the file on file descriptor FD.
if ! eval "exec $FD<$FILENAME"; then
    exit 1
fi

# Read all lines from FD.
while read -u $FD a_line; do
    echo "Read \"$a_line\"."
done

# Close FD.
eval "exec $FD<&-"

1
很高兴这个答案仍然存在,因为它仍然有用。现在是2020年,我使用最新的Max OS X更新(Catalina),但我的Bash版本仍然不是4.1或更高版本,而是停留在3.2.57。我想苹果已经转向了“zsh”,但这并不是借口。 - IAM_AL_X

1

注意:许多现代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}< filenameexec {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<&mn>&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 没有编程重试,则来自于竞争条件(即 EINTREBUSY)。

[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 …


0

苹果的Mac OS X不是Linux。我在OS X上没有看到任何“/proc”文件系统。

我猜一个答案是使用“zsh”,但我想要一个在“bash”上同时适用于OS X(也称为BSD)和Linux的脚本。所以,现在是2020年,我使用最新版本的OS X,即Catalina,我意识到苹果似乎早就放弃了Bash的维护;显然是为了Zsh。

这是我在Apple Mac OS X或Linux上查找最低未使用文件描述符的多操作系统解决方案。我创建了一个完整的Perl脚本,并将其内联到Shell脚本中。肯定有更好的方法,但目前对我来说,这个方法可行。

lowest_unused_fd() {
  # For "bash" version 4.1 and higher, and for "zsh", this entire function  
  # is replaced by the more modern operator "{fd}", used like this:
  #    exec {FD}>myFile.txt; echo "hello" >&$FD;
  if [ $(uname) = 'Darwin' ] ; then
    lsof -p $$ -a -d 0-32 | perl -an \
      -e 'BEGIN { our @currentlyUsedFds; };' \
      -e '(my $digits = $F[3]) =~ s/\D//g;' \
      -e 'next if $digits eq "";' \
      -e '$currentlyUsedFds[$digits] = $digits;' \
      -e 'END { my $ix; 
            for( $ix=3; $ix <= $#currentlyUsedFds; $ix++) {  
              my $slotContents = $currentlyUsedFds[$ix];
              if( !defined($slotContents) ) { 
                last; 
              } 
            } 
            print $ix;
          }' ;
  else 
    local FD=3
    while [ -e /proc/$$/fd/$FD ]; do
      FD=$((FD+1))
    done
    echo $FD
  fi;
}

Perl中的-an选项告诉它(-n)运行一个隐含的while()循环,逐行读取文件并将其自动分割成一个单词数组,按照惯例,该数组被命名为@FBEGIN指定在while()循环之前要执行的操作,而END则指定在之后要执行的操作。while()循环挑选出每行的第三个字段,将其缩减为仅包含前导数字的端口号,并将其保存在当前正在使用且因此不可用的端口号数组中。END块然后找到最低的整数,其插槽未被占用。

更新: 在完成所有这些操作后,我实际上没有在自己的代码中使用它。 我意识到KingPong和Bruno Bronsky的答案更加优雅。 但是,我将保留此答案;对某些人可能会有趣。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接