Help with channels/fibers

Hey everyone, does anyone know why Received keystroke: ... is only being printed every second keystroke here?

# Function to read from /dev/tty and send keystrokes to a channel
def read_tty_and_send(channel : Channel(Char))
  File.open("/dev/tty", "r") do |tty|
    while char = tty.raw &.read_char
      channel.send char
    end
  end
rescue ex
  puts "Error reading from /dev/tty: #{ex.message}"
end

# Create a channel for Char
keystroke_channel = Channel(Char).new

# Start the tty reading in a separate fiber
spawn read_tty_and_send(keystroke_channel)

# Read from the channel
while char = keystroke_channel.receive
  puts "Received keystroke: #{char}"
end
1 Like

I got it now - the problem was that I was thinking in a parallel execution scenario. Turns out the behavior is different if you enable the multi-threading flag. Without the flag you need the Fiber.yield call - otherwise the receive fiber (main fiber in this case) is not called/handed-over until the (unbuffered but technically still buffered) buffer is full.

def read_tty_and_send(channel : Channel(Char))
  File.open("/dev/tty", "r") do |tty|
    while char = tty.raw &.read_char
      channel.send(char)
      Fiber.yield
    end 
  end

Now without Fiber.yield and without MT a buffer size of 1 (unbuffered) will first write the char to the buffer and only every second keystroke will yield to the receiving fiber

The behavior is different with MT, of course, because real parallelization will switch between the fibers automatically.

Which makes sense, apparently one just needs to be very careful to not mix up concurrent and parallel execution environments when dealing with fibers and channels.

This seems odd. There should be no need for Fiber.yield after sending to a channel. Channel takes care of resuming waiting fibers.

I was able to reproduce this.
For clarity, the behaviour is that after the first keystroke, there’s no output. But after the second keystroke, there are two lines of output. This continues: every n-1 and n keystroke are printed together on the n keystroke.

2 Likes

How would I ensure that after the first channel send the other fiber is resumed? I mean: On the first keystroke the channel is empty, meaning that channel send will return immediately, put the message into the buffer with size 1 and starts waiting for the next char. Only if the buffer is full, the handover happens. Is there any best-practice for a scenario like this?

As I remember there is some locker exists for STDOUT for puts to avoid concurent writing and has some buffer before printing.

It could block the experiments related to checking concurency.

UPDATE: It should not be the case for this example, as puts happen only in main fiber.

Yeah it’s not STDOUT buffering. The same behaviour is observed when writing directly via Crystal::System.print_error.

Without multi-threading, the order of execution is actually supposed to be like this:

  1. keystroke_channel.receive is called first. The channel is empty so the fiber cannot continue.
  2. It enques as a receiver and the scheduler reschedules.
  3. Only at this point the nested fiber starts running.
  4. It reads from the file descriptor and channel.send char should find that there’s already a receiver waiting.
  5. It directly pushes the value forward and continues the waiting main fiber.
1 Like

So is this a bug? I can create an issue if you like

Ah no, I got confused. Channel#send does not automatically resume a waiting fiber immediately. It just enqueues it, which means it runs the next time the scheduler has an opportunity to swap fibers.
Introducing this opportunity with Fiber.yield is probably correct then.

I’m wondering if it could make sense to resume a waiting fiber directly, though :thinking:

The problem is that tty.raw(&.read_char) is blocking the whole thread until it can read a char, blocking other fibers. If it was waiting on IO, the other fibers would be given a chance to run.

This situation can also happen with MT: if you’re unlucky both fibers will be on the same thread.

@davidmatter I found the bug!

I first thought it was cfmakeraw(3) that sets VMIN=1 and VTIME=0 (block until at least 1 byte is written) that made read(2) blocking but the problem was that File forces blocking to true (see original commit). It should be fine usually (though I wonder about reading a file that is being written to by another process :thinking:), except that you’re not reading a regular file, but an interactive character device (/dev/tty)!

IO::FileDescriptor takes care to detect this, as well as pipes and sockets, but File forces blocking to true, so the tty.raw(&.read_char) becomes a blocking call :scream:

Adding tty.blocking = true fixes the issue: #read_char won’t block anymore but trigger the event-loop, which will resume the receiving fiber that got enqueued by the channel. I’ll try to get a patch in stdlib for File to behave like IO::FileDescriptor.

Note: you’ll have issues with the receiving channel being resumed while the RAW mode has been activated on the TTY (puts adds \n not \r\n :smile:).

5 Likes

Great, thanks for the help and the investigation

Is there a way to detect if it should be blocking or not, or should the tty opening simply use FileDescriptor directly? Because setting regular block device files to nonblocking is kinda weird?

@yxhuvud I created a PR where I expose a blocking parameter to File.new (and methods that use it) that defaults to true but can be set to nil (autodetect) or false (nonblocking). Since we expose File#blocking= it makes sense to expose it right to the constructor —and later we might be able to avoid blocking when opening fifo files.

IO::FileDescriptor wouldn’t be enough since we still have to open the file (we don’t have a fd but a pathname), and only File.new is capable of that.

So the example would become:

File.open("/dev/tty", "r", blocking: false) do |tty|
end
3 Likes