Bash: assigning the output of 'times' builtin to a variable

The fundamental problem that you need to solve is how to get both the execution of time and the variable assignment to happen in the same shell, without a temporary file. Almost every method Bash provides of piping the output of one thing to another, or capturing the output of a command, has one side working in a subshell.

Here is one way you can do it without a temporary file, but I'll warn you, it's not pretty, it's not portable to other shells, and it requires at least Bash 4:

coproc co { cat; }; times 1>&${co[1]}; eval "exec ${co[1]}>&-"; mapfile -tu ${co[0]} times_a

I'll break this down for you:

coproc co { cat; }

This creates a coprocess; a process which runs in the background, but you are given pipes to talk to its standard input and standard output, which are the FDs ${co[0]} (standard out of cat) and ${co[1]} (standard in of cat). The commands are executed in a subshell, so we can't do either of our goals in there (running times or reading into a variable), but we can use cat to simply pass the input through to the output, and then use that pipe to talk to times and mapfile in the current shell.

times >&${co[1]};

Run times, redirecting its standard out to the standard in of the cat command.

eval "exec ${co[1]}>&-"

Close the input end of the cat command. If we don't do this, cat will continue waiting for input, keeping its output open, and mapfile will continue waiting for that, causing your shell to hang. exec, when passed no commands, simply applies its redirections to the current shell; redirecting to - closes an FD. We need to use eval because Bash seems to have trouble with exec ${co[1]}>&-, interpreting the FD as the command instead of part of the redirection; using eval allows that variable to be substituted first, and then then executed.

mapfile -tu ${co[0]} times_a

Finally we actually read the data from the standard out of the coprocess. We've managed to run both the times and the mapfile command in this shell, and used no temporary files, though we did use a temporary process as a pipeline between the two commands.

Note that this has a subtle race. If you execute these commands one by one, instead of all as one command, the last one fails; because when you close cats standard in, it exits, causing the coprocess to exit and FDs to be closed. It appears that when executed all on one line, mapfile is executed quickly enough that the coprocess is still open when it runs, and thus it can read from the pipe; but I may be getting lucky. I haven't figured out a good way around this.

All told, it's much simpler just to write out the temp file. I would use mktemp to generate a filename, and if you're in a script, add a trap to ensure that you clean up your tempfile before exiting:

tempnam=$(mktemp)
trap "rm '$tempnam'" EXIT
times > ${tempnam}
mapfile -t times_a < ${tempnam}

Brian's answer got me very interested in this problem, leading to this solution which has no race condition:

coproc cat;
times >&${COPROC[1]};
{
  exec {COPROC[1]}>&-;
  mapfile -t times_a;
} <&${COPROC[0]};

This is quite similar in underlying structure to Brian's solution, but there are some key differences that ensure no funny-business occurs due to timing issues. As Brian stated, his solution typically works because the bash interpreter starts running the mapfile command before the coprocess' file descriptors have been fully closed and cleaned up, so any unexpected delay before or during mapfile would break it.


Essentially, the coprocess' stdout file descriptor gets closed a moment after we close the coprocess' stdin file descriptor. We need a way to preserve the coprocess' stdout.

In the man pages for pipe, we find:

If all file descriptors referring to the read end of a pipe have been closed, then a write(2) will cause a SIGPIPE signal to be generated for the calling process.

Thus, we must preserve at least one file descriptor to the coprocess' stdout. This is easy enough to accomplish with Redirections. We can do something like exec 3<&${COPROC[0]}- to move the coprocess' stdout file descriptor to a newly created fd 31. When the coprocess terminates, we will still have a file descriptor for its stdout and be able to read from it.


Now, we can do the following:

  1. Create a coprocess that does nothing but cat.

    coproc cat;
    
  2. Redirect times's stdout to the coprocess' stdin.

    times >&${COPROC[1]};
    
  3. Get a temporary copy of the coprocess' stdout file descriptor.

  4. Close the coprocess' stdin file descriptor. (If using a version before Bash-4.3, you can use the eval trick Brian used.)

    exec 3<&${COPROC[0]} {COPROC[1]}>&-;
    
  5. Read from our temporary file descriptor into a variable.

    mapfile -tu 3 times_a;
    
  6. Close our temporary file descriptor (not necessary, but good practice).

    exec 3<&-;
    

And we're done! However, we still have some opportunities to restructure to make things neater. Thanks to the nature of redirection syntax, this code:

coproc cat;
times >&${COPROC[1]};
exec 3<&${COPROC[0]} {COPROC[1]}>&-;
mapfile -tu 3 times_a;
exec 3<&-;

behaves identically to this code:

coproc cat;
times >&${COPROC[1]};
{  
  exec {COPROC[1]}>&-;
  mapfile -tu 3 times_a;
} 3<&${COPROC[0]};

From here, we can remove the temporary file descriptor altogether, leading to our solution:

Bash 4.3+:

coproc cat; times >&${COPROC[1]}; { exec {COPROC[1]}>&-; mapfile -t times_a; } <&${COPROC[0]}

Bash 4.0+:

coproc cat; times >&${COPROC[1]}; { eval "exec ${COPROC[1]}>&-"; mapfile -t times_a; } <&${COPROC[0]}

1We do not need to close the original file descriptor here, and can just duplicate it, as the original gets closed when the stdin descriptor gets closed

Tags:

Bash