如何给Console.ReadLine()添加超时时间?

138

我有一个控制台应用程序,我想在其中给用户x秒来响应提示。如果一定时间后没有输入,则应继续执行程序逻辑。我们假设超时意味着空响应。

最直接的方法是什么?

33个回答

126

我惊讶地发现,经过5年后,所有答案仍然存在以下一个或多个问题:

  • 使用ReadLine之外的功能,导致功能丧失。(删除/退格/上方向键用于先前输入)。
  • 当被调用多次时,函数表现不佳(产生多个线程、多个挂起的ReadLine或其他意外行为)。
  • 函数依赖于繁忙等待。由于等待时间可能从几秒钟到超时,可能需要多分钟,所以这是一种可怕的浪费。在多线程场景中,这是特别糟糕的资源浪费。如果使用sleep修改繁忙等待,则会对响应能力产生负面影响,尽管我承认这可能不是一个很大的问题。

我相信我的解决方案将解决原始问题,而且不会遭受以上任何问题:

class Reader {
  private static Thread inputThread;
  private static AutoResetEvent getInput, gotInput;
  private static string input;

  static Reader() {
    getInput = new AutoResetEvent(false);
    gotInput = new AutoResetEvent(false);
    inputThread = new Thread(reader);
    inputThread.IsBackground = true;
    inputThread.Start();
  }

  private static void reader() {
    while (true) {
      getInput.WaitOne();
      input = Console.ReadLine();
      gotInput.Set();
    }
  }

  // omit the parameter to read a line without a timeout
  public static string ReadLine(int timeOutMillisecs = Timeout.Infinite) {
    getInput.Set();
    bool success = gotInput.WaitOne(timeOutMillisecs);
    if (success)
      return input;
    else
      throw new TimeoutException("User did not provide input within the timelimit.");
  }
}

呼叫当然非常容易:

try {
  Console.WriteLine("Please enter your name within the next 5 seconds.");
  string name = Reader.ReadLine(5000);
  Console.WriteLine("Hello, {0}!", name);
} catch (TimeoutException) {
  Console.WriteLine("Sorry, you waited too long.");
}

你也可以使用TryXX(out)约定,正如Shmueli所建议的那样:

  public static bool TryReadLine(out string line, int timeOutMillisecs = Timeout.Infinite) {
    getInput.Set();
    bool success = gotInput.WaitOne(timeOutMillisecs);
    if (success)
      line = input;
    else
      line = null;
    return success;
  }

被称为如下:

Console.WriteLine("Please enter your name within the next 5 seconds.");
string name;
bool success = Reader.TryReadLine(out name, 5000);
if (!success)
  Console.WriteLine("Sorry, you waited too long.");
else
  Console.WriteLine("Hello, {0}!", name);

在这两种情况下,您不能将Reader的调用与普通的Console.ReadLine调用混合使用:如果Reader超时,就会出现挂起的ReadLine调用。相反,如果要进行正常(非定时)的ReadLine调用,请只使用Reader并省略超时,以使其默认为无限超时。

那么其他解决方案的问题怎么样呢?

  • 如您所见,使用了ReadLine,避免了第一个问题。
  • 该函数在多次调用时表现正常。无论是否发生超时,始终只有一个后台线程在运行,并且最多只有一个对ReadLine的调用是活动的。调用该函数将始终产生最新的输入或超时,并且用户不必多次按Enter键提交输入。
  • 显然,该函数不依赖于繁忙等待。相反,它使用适当的多线程技术来防止浪费资源。

我预见到这个解决方案唯一的问题是它不是线程安全的。但是,多个线程实际上不能同时向用户请求输入,因此在调用Reader.ReadLine之前应该进行同步。


1
我在这段代码后面得到了一个NullReferenceException异常。我认为我可以通过在自动事件创建时启动线程一次来解决它。 - Augusto Pedraza
1
@JSQuareD 我不认为使用 Sleep(200ms) 的忙等待是一个“可怕的浪费”,但当然你的信号量更优秀。此外,在第二个线程中使用一个阻塞的 Console.ReadLine 调用来进行无限循环,可以避免在其他得到高票的解决方案中出现大量这样的调用挂起的问题。感谢您分享您的代码。+1 - Roland
3
如果您未能及时输入,那么在您进行第一个后续的 Console.ReadLine() 调用时,该方法似乎会出现错误。您最终会遇到一个需要先完成的“幽灵”ReadLine - Derek
1
@Derek,不幸的是,您不能将此方法与普通的ReadLine调用混合使用,所有调用都必须通过Reader进行。解决此问题的方法是向Reader添加一个方法,该方法在没有超时的情况下等待gotInput。我目前正在使用移动设备,因此无法轻松地将其添加到答案中。 - JSQuareD
1
我不认为需要 getInput - silvalli
显示剩余16条评论

32
string ReadLine(int timeoutms)
{
    ReadLineDelegate d = Console.ReadLine;
    IAsyncResult result = d.BeginInvoke(null, null);
    result.AsyncWaitHandle.WaitOne(timeoutms);//timeout e.g. 15000 for 15 secs
    if (result.IsCompleted)
    {
        string resultstr = d.EndInvoke(result);
        Console.WriteLine("Read: " + resultstr);
        return resultstr;
    }
    else
    {
        Console.WriteLine("Timed out!");
        throw new TimedoutException("Timed Out!");
    }
}

delegate string ReadLineDelegate();

2
我不知道为什么这个代码没有被投票赞成——它完全无误。许多其他的解决方案都涉及到“ReadKey()”,但它不能正常工作:这意味着您将失去ReadLine()的所有功能,如按“上”键获取先前输入的命令,使用退格和箭头键等。 - Contango
9
@Gravitas: 这个不起作用。嗯,它可以工作一次。但是每次调用 ReadLine 都会卡在那里等待输入。如果你调用它100次,它将创建100个线程,直到你按下100次回车键之前并不会全部消失! - Gabe
2
注意。这个解决方案看起来很好,但我最终留下了数千个未完成的呼叫。因此,如果需要重复调用,则不适合使用。 - Tom Makin
如何解决内存泄漏问题?否则,我喜欢这个解决方案的原因在于它几乎遵循了微软在https://msdn.microsoft.com/en-us/library/2e08f6yc%28v=vs.110%29.aspx上关于将同步调用转换为异步的一般问题的建议。 - Roland
我最初喜欢你的解决方案,因为它符合微软关于如何将同步调用转换为异步的信息,但我发现如何在超时时修复缺失的 EndInvoke 调用的问题无法解决。另一方面,接受的解决方案在第二个线程中运行一个永久的同步调用是完美的。感谢您的努力,但我不能使用您的解决方案。-1 抱歉。 - Roland
显示剩余4条评论

28

使用Console.KeyAvailable这种方法,是否有帮助?

class Sample 
{
    public static void Main() 
    {
    ConsoleKeyInfo cki = new ConsoleKeyInfo();

    do {
        Console.WriteLine("\nPress a key to display; press the 'x' key to quit.");

// Your code could perform some useful task in the following loop. However, 
// for the sake of this example we'll merely pause for a quarter second.

        while (Console.KeyAvailable == false)
            Thread.Sleep(250); // Loop until input is entered.
        cki = Console.ReadKey(true);
        Console.WriteLine("You pressed the '{0}' key.", cki.Key);
        } while(cki.Key != ConsoleKey.X);
    }
}

这是正确的,OP似乎确实想要一个阻塞调用,尽管我有点不敢想象...这可能是一个更好的解决方案。 - GEOCHET
我相信你已经看过这个了。从快速谷歌搜索中得到的:http://social.msdn.microsoft.com/forums/en-US/csharpgeneral/thread/5f954d01-dbaf-410a-9b0d-6bb6f57d0b85/ - Gulzar Nazim
我不明白如果用户什么都不做,这个程序怎么会“超时”。这只会让后台逻辑一直执行下去,直到有按键被按下并且其他逻辑也在继续执行。 - mphair
是的,这需要修复。但是将超时时间添加到循环条件中足够容易。 - Jonathan Allen
KeyAvailable只表示用户已经开始输入ReadLine的内容,但我们需要在按下Enter键时触发一个事件,使ReadLine返回。这个解决方案仅适用于ReadKey,即只获取一个字符。由于这不能解决ReadLine的实际问题,所以我无法使用你的解决方案。-1抱歉。 - Roland

15

这对我起作用了。

ConsoleKeyInfo k = new ConsoleKeyInfo();
Console.WriteLine("Press any key in the next 5 seconds.");
for (int cnt = 5; cnt > 0; cnt--)
  {
    if (Console.KeyAvailable)
      {
        k = Console.ReadKey();
        break;
      }
    else
     {
       Console.WriteLine(cnt.ToString());
       System.Threading.Thread.Sleep(1000);
     }
 }
Console.WriteLine("The key pressed was " + k.Key);

4
我觉得这是最好、最简单的解决方案,使用已经内置的工具。做得很好! - Vippy
2
太棒了!简洁确实是最终的精髓。恭喜! - brunosp86

10

如果你在Main()方法中,无法使用await,所以你需要使用Task.WaitAny()

var task = Task.Factory.StartNew(Console.ReadLine);
var result = Task.WaitAny(new Task[] { task }, TimeSpan.FromSeconds(5)) == 0
    ? task.Result : string.Empty;

然而,C# 7.1引入了创建异步Main()方法的可能性,因此最好在有这个选项的情况下使用Task.WhenAny()版本:

var task = Task.Factory.StartNew(Console.ReadLine);
var completedTask = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(5)));
var result = object.ReferenceEquals(task, completedTask) ? task.Result : string.Empty;

10

无论如何,您确实需要第二个线程。您可以使用异步IO来避免声明自己的线程:

  • 声明一个ManualResetEvent,将其命名为“evt”
  • 调用System.Console.OpenStandardInput以获取输入流。指定一个回调方法来存储其数据并设置evt。
  • 调用该流的BeginRead方法启动异步读取操作
  • 然后在ManualResetEvent上进入定时等待
  • 如果等待超时,则取消读取

如果读取返回数据,则设置事件,您的主线程将继续执行,否则在超时后继续执行。


这基本上就是被接受的解决方案所做的事情。 - Roland

9
// Wait for 'Enter' to be pressed or 5 seconds to elapse
using (Stream s = Console.OpenStandardInput())
{
    ManualResetEvent stop_waiting = new ManualResetEvent(false);
    s.BeginRead(new Byte[1], 0, 1, ar => stop_waiting.Set(), null);

    // ...do anything else, or simply...

    stop_waiting.WaitOne(5000);
    // If desired, other threads could also set 'stop_waiting' 
    // Disposing the stream cancels the async read operation. It can be
    // re-opened if needed.
}

8

我认为你需要创建一个次线程并轮询控制台上的按键。我不知道有没有内置的方法可以实现这个功能。


如果你有第二个线程在轮询键,而当你的应用程序关闭时它仍在等待,那么这个键轮询线程将会一直等待下去。 - Kelly Elton
实际上:要么使用第二个线程,要么使用带有“BeginInvoke”的委托(在幕后使用线程 - 请参见@gp的答案)。 - Contango
@kelton52,如果您在任务管理器中结束进程,次要线程会退出吗? - Arlen Beiler

6

在企业环境中,我花了五个月的时间才找到了一个完美解决这个问题的方法。

目前大多数解决方案的问题在于它们依赖于除Console.ReadLine()之外的其他东西,而Console.ReadLine()具有许多优点:

  • 支持删除、退格、箭头键等功能。
  • 能够按“上”键并重复上一条命令(如果你实现了一个经常使用的后台调试控制台,这非常方便)。

我的解决方案如下:

  1. 使用Console.ReadLine()在一个单独的线程中处理用户输入。
  2. 在超时期后,通过http://inputsimulator.codeplex.com/向当前控制台窗口发送[enter]键,以解除Console.ReadLine()的阻塞。

示例代码:

 InputSimulator.SimulateKeyPress(VirtualKeyCode.RETURN);

关于这种技术的更多信息,包括正确的技术来中止使用Console.ReadLine的线程:

.NET调用以向当前进程(控制台应用程序)发送[enter]按键

如何在.NET中中止另一个正在执行Console.ReadLine的线程?


6

在委托中调用Console.ReadLine()是不好的,因为如果用户没有按下“回车”键,那么该调用将永远不会返回。执行委托的线程将被阻塞,直到用户按下“回车”键,无法取消它。

发出一系列这些调用将不会像您期望的那样工作。考虑以下示例(使用上面的Console类):

System.Console.WriteLine("Enter your first name [John]:");

string firstName = Console.ReadLine(5, "John");

System.Console.WriteLine("Enter your last name [Doe]:");

string lastName = Console.ReadLine(5, "Doe");

用户让第一个提示超时,然后输入第二个提示的值。firstName和lastName都将包含默认值。当用户按下“回车”键时,第一个ReadLine调用将完成,但代码已经放弃了该调用并且基本上丢弃了结果。第二个ReadLine调用将继续阻塞,超时最终会过期,并且返回的值再次是默认值。
顺便说一下,上面的代码中存在一个错误。通过调用waitHandle.Close(),您关闭了工作线程下的事件。如果用户在超时后按下“回车”键,则工作线程将尝试发出信号,这会引发ObjectDisposedException异常。异常是从工作线程抛出的,如果您没有设置未处理的异常处理程序,则进程将终止。

1
你的帖子中的术语“above”含糊不清且令人困惑。如果你是在引用另一个答案,应该使用正确的链接指向那个答案。 - bzlm

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