如何在超时时间后取消任务等待

14

我正在使用这种方法来以编程方式实例化一个Web浏览器,导航到一个URL,并在文档完成后返回结果。

如果文档加载需要超过5秒钟,我该如何停止Task并使GetFinalUrl()返回null

我看过许多使用TaskFactory的示例,但我无法将其应用于此代码。

 private Uri GetFinalUrl(PortalMerchant portalMerchant)
    {
        SetBrowserFeatureControl();
        Uri finalUri = null;
        if (string.IsNullOrEmpty(portalMerchant.Url))
        {
            return null;
        }
        Uri trackingUrl = new Uri(portalMerchant.Url);
        var task = MessageLoopWorker.Run(DoWorkAsync, trackingUrl);
        task.Wait();
        if (!String.IsNullOrEmpty(task.Result.ToString()))
        {
            return new Uri(task.Result.ToString());
        }
        else
        {
            throw new Exception("Parsing Failed");
        }
    }

// by Noseratio - http://stackoverflow.com/users/1768303/noseratio    

static async Task<object> DoWorkAsync(object[] args)
{
    _threadCount++;
    Console.WriteLine("Thread count:" + _threadCount);
    Uri retVal = null;
    var wb = new WebBrowser();
    wb.ScriptErrorsSuppressed = true;

    TaskCompletionSource<bool> tcs = null;
    WebBrowserDocumentCompletedEventHandler documentCompletedHandler = (s, e) => tcs.TrySetResult(true);

    foreach (var url in args)
    {
        tcs = new TaskCompletionSource<bool>();
        wb.DocumentCompleted += documentCompletedHandler;
        try
        {
            wb.Navigate(url.ToString());
            await tcs.Task;
        }
        finally
        {
            wb.DocumentCompleted -= documentCompletedHandler;
        }

        retVal = wb.Url;
        wb.Dispose();
        return retVal;
    }
    return null;
}

public static class MessageLoopWorker
{
    #region Public static methods

    public static async Task<object> Run(Func<object[], Task<object>> worker, params object[] args)
    {
        var tcs = new TaskCompletionSource<object>();

        var thread = new Thread(() =>
        {
            EventHandler idleHandler = null;

            idleHandler = async (s, e) =>
            {
                // handle Application.Idle just once
                Application.Idle -= idleHandler;

                // return to the message loop
                await Task.Yield();

                // and continue asynchronously
                // propogate the result or exception
                try
                {
                    var result = await worker(args);
                    tcs.SetResult(result);
                }
                catch (Exception ex)
                {
                    tcs.SetException(ex);
                }

                // signal to exit the message loop
                // Application.Run will exit at this point
                Application.ExitThread();
            };

            // handle Application.Idle just once
            // to make sure we're inside the message loop
            // and SynchronizationContext has been correctly installed
            Application.Idle += idleHandler;
            Application.Run();
        });

        // set STA model for the new thread
        thread.SetApartmentState(ApartmentState.STA);

        // start the thread and await for the task
        thread.Start();
        try
        {
            return await tcs.Task;
        }
        finally
        {
            thread.Join();
        }
    }
    #endregion
}

1
很高兴看到有人真正使用这段代码 :) 我还有另一个例子,它使用超时来完成类似的事情:https://dev59.com/Cnvaa4cB1Zd3GeqPB1aY#21152965。查找变量`cts = new CancellationTokenSource(30000)`。 - noseratio - open to work
谢谢。你有在控制台应用程序中实现这个的例子吗?另外,我认为webBrowser不能是一个类变量,因为我正在使用并行for each迭代数千个URL。 - Dan Cook
我在我的控制台应用程序中使用了您建议的代码,并得到了以下错误信息:System.Threading.ThreadStateException: 无法实例化ActiveX控件“8856f961-340a-11d0-a96b-00c04fd705a2”,因为当前线程不在单线程公寓中。我猜这就是您其他代码示例中消息循环工作线程所做的事情。这也是我无法通过cancellationToken使其正常工作的原因。感谢您的帮助,我会继续尝试。 - Dan Cook
似乎不仅需要在STA线程上运行,还需要一个消息循环工作器,如:https://dev59.com/onjZa4cB1Zd3GeqPgqqG#19737374 - Dan Cook
3个回答

25

更新: 基于WebBrowser的控制台网络爬虫的最新版本可以在Github上找到.

更新: 为多个并行下载添加WebBrowser对象池的例子。

你有在控制台应用程序中实现这个功能的示例吗?另外,我认为WebBrowser不能是一个类变量,因为我正在使用parallell foreach在成千上万个URL上迭代

下面是一个基本的WebBrowser网络爬虫实现,它作为控制台应用程序工作。它包括了我之前与WebBrowser相关的一些工作,包括问题中引用的代码:

几点说明:

  • 可重用的MessageLoopApartment类用于启动和运行一个带有自己消息泵的WinForms STA线程。可以从控制台应用程序中使用,如下所示。该类公开了一个TPL任务调度器(FromCurrentSynchronizationContext)和一组Task.Factory.StartNew包装器以使用此任务调度器。

  • 这使得async/await成为在单独的STA线程上运行WebBrowser导航任务的强大工具。这样,WebBrowser对象就在该线程上创建、导航和销毁。尽管,MessageLoopApartment没有专门绑定到WebBrowser

  • 重要的是要使用浏览器功能控件启用HTML5渲染,否则默认情况下WebBrowser对象将以IE7仿真模式运行。这就是下面的SetFeatureBrowserEmulation所做的。

  • 可能无法始终以100%的概率确定网页是否已完成渲染。有些页面非常复杂,使用连续的AJAX更新。但我们可以通过首先处理DocumentCompleted事件,然后轮询页面的当前HTML快照以进行更改并检查WebBrowser.IsBusy属性来接近这一点。这就是下面的NavigateAsync所做的。

  • 在上述基础上还存在超时逻辑,以防页面渲染永无止境(请注意CancellationTokenSourceCreateLinkedTokenSource)。

using Microsoft.Win32;
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace Console_22239357
{
    class Program
    {
        // by Noseratio - https://dev59.com/OGEh5IYBdhLWcg3wlEZq#22262976

        // main logic
        static async Task ScrapeSitesAsync(string[] urls, CancellationToken token)
        {
            using (var apartment = new MessageLoopApartment())
            {
                // create WebBrowser inside MessageLoopApartment
                var webBrowser = apartment.Invoke(() => new WebBrowser());
                try
                {
                    foreach (var url in urls)
                    {
                        Console.WriteLine("URL:\n" + url);

                        // cancel in 30s or when the main token is signalled
                        var navigationCts = CancellationTokenSource.CreateLinkedTokenSource(token);
                        navigationCts.CancelAfter((int)TimeSpan.FromSeconds(30).TotalMilliseconds);
                        var navigationToken = navigationCts.Token;

                        // run the navigation task inside MessageLoopApartment
                        string html = await apartment.Run(() =>
                            webBrowser.NavigateAsync(url, navigationToken), navigationToken);

                        Console.WriteLine("HTML:\n" + html);
                    }
                }
                finally
                {
                    // dispose of WebBrowser inside MessageLoopApartment
                    apartment.Invoke(() => webBrowser.Dispose());
                }
            }
        }

        // entry point
        static void Main(string[] args)
        {
            try
            {
                WebBrowserExt.SetFeatureBrowserEmulation(); // enable HTML5

                var cts = new CancellationTokenSource((int)TimeSpan.FromMinutes(3).TotalMilliseconds);

                var task = ScrapeSitesAsync(
                    new[] { "http://example.com", "http://example.org", "http://example.net" },
                    cts.Token);

                task.Wait();

                Console.WriteLine("Press Enter to exit...");
                Console.ReadLine();
            }
            catch (Exception ex)
            {
                while (ex is AggregateException && ex.InnerException != null)
                    ex = ex.InnerException;
                Console.WriteLine(ex.Message);
                Environment.Exit(-1);
            }
        }
    }

    /// <summary>
    /// WebBrowserExt - WebBrowser extensions
    /// by Noseratio - https://dev59.com/OGEh5IYBdhLWcg3wlEZq#22262976
    /// </summary>
    public static class WebBrowserExt
    {
        const int POLL_DELAY = 500;

        // navigate and download 
        public static async Task<string> NavigateAsync(this WebBrowser webBrowser, string url, CancellationToken token)
        {
            // navigate and await DocumentCompleted
            var tcs = new TaskCompletionSource<bool>();
            WebBrowserDocumentCompletedEventHandler handler = (s, arg) =>
                tcs.TrySetResult(true);

            using (token.Register(() => tcs.TrySetCanceled(), useSynchronizationContext: true))
            {
                webBrowser.DocumentCompleted += handler;
                try
                {
                    webBrowser.Navigate(url);
                    await tcs.Task; // wait for DocumentCompleted
                }
                finally
                {
                    webBrowser.DocumentCompleted -= handler;
                }
            }

            // get the root element
            var documentElement = webBrowser.Document.GetElementsByTagName("html")[0];

            // poll the current HTML for changes asynchronosly
            var html = documentElement.OuterHtml;
            while (true)
            {
                // wait asynchronously, this will throw if cancellation requested
                await Task.Delay(POLL_DELAY, token);

                // continue polling if the WebBrowser is still busy
                if (webBrowser.IsBusy)
                    continue;

                var htmlNow = documentElement.OuterHtml;
                if (html == htmlNow)
                    break; // no changes detected, end the poll loop

                html = htmlNow;
            }

            // consider the page fully rendered 
            token.ThrowIfCancellationRequested();
            return html;
        }

        // enable HTML5 (assuming we're running IE10+)
        // more info: https://dev59.com/VmMl5IYBdhLWcg3wgnWl#18333982
        public static void SetFeatureBrowserEmulation()
        {
            if (System.ComponentModel.LicenseManager.UsageMode != System.ComponentModel.LicenseUsageMode.Runtime)
                return;
            var appName = System.IO.Path.GetFileName(System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName);
            Registry.SetValue(@"HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\Main\FeatureControl\FEATURE_BROWSER_EMULATION",
                appName, 10000, RegistryValueKind.DWord);
        }
    }

    /// <summary>
    /// MessageLoopApartment
    /// STA thread with message pump for serial execution of tasks
    /// by Noseratio - https://dev59.com/OGEh5IYBdhLWcg3wlEZq#22262976
    /// </summary>
    public class MessageLoopApartment : IDisposable
    {
        Thread _thread; // the STA thread

        TaskScheduler _taskScheduler; // the STA thread's task scheduler

        public TaskScheduler TaskScheduler { get { return _taskScheduler; } }

        /// <summary>MessageLoopApartment constructor</summary>
        public MessageLoopApartment()
        {
            var tcs = new TaskCompletionSource<TaskScheduler>();

            // start an STA thread and gets a task scheduler
            _thread = new Thread(startArg =>
            {
                EventHandler idleHandler = null;

                idleHandler = (s, e) =>
                {
                    // handle Application.Idle just once
                    Application.Idle -= idleHandler;
                    // return the task scheduler
                    tcs.SetResult(TaskScheduler.FromCurrentSynchronizationContext());
                };

                // handle Application.Idle just once
                // to make sure we're inside the message loop
                // and SynchronizationContext has been correctly installed
                Application.Idle += idleHandler;
                Application.Run();
            });

            _thread.SetApartmentState(ApartmentState.STA);
            _thread.IsBackground = true;
            _thread.Start();
            _taskScheduler = tcs.Task.Result;
        }

        /// <summary>shutdown the STA thread</summary>
        public void Dispose()
        {
            if (_taskScheduler != null)
            {
                var taskScheduler = _taskScheduler;
                _taskScheduler = null;

                // execute Application.ExitThread() on the STA thread
                Task.Factory.StartNew(
                    () => Application.ExitThread(),
                    CancellationToken.None,
                    TaskCreationOptions.None,
                    taskScheduler).Wait();

                _thread.Join();
                _thread = null;
            }
        }

        /// <summary>Task.Factory.StartNew wrappers</summary>
        public void Invoke(Action action)
        {
            Task.Factory.StartNew(action,
                CancellationToken.None, TaskCreationOptions.None, _taskScheduler).Wait();
        }

        public TResult Invoke<TResult>(Func<TResult> action)
        {
            return Task.Factory.StartNew(action,
                CancellationToken.None, TaskCreationOptions.None, _taskScheduler).Result;
        }

        public Task Run(Action action, CancellationToken token)
        {
            return Task.Factory.StartNew(action, token, TaskCreationOptions.None, _taskScheduler);
        }

        public Task<TResult> Run<TResult>(Func<TResult> action, CancellationToken token)
        {
            return Task.Factory.StartNew(action, token, TaskCreationOptions.None, _taskScheduler);
        }

        public Task Run(Func<Task> action, CancellationToken token)
        {
            return Task.Factory.StartNew(action, token, TaskCreationOptions.None, _taskScheduler).Unwrap();
        }

        public Task<TResult> Run<TResult>(Func<Task<TResult>> action, CancellationToken token)
        {
            return Task.Factory.StartNew(action, token, TaskCreationOptions.None, _taskScheduler).Unwrap();
        }
    }
}

1
谢谢Noseratio。这段代码完美地运行了,我可以轻松地将其调整以适应我的需求。我正在使用它在一个并行的foreach中,它非常稳定。如果你需要在控制台应用程序中使用Web浏览器解析多个URL,请不要再寻找了。谢谢! - Dan Cook
2
@DanCook,不用担心,很高兴能帮到你。如果你正在并行处理,请确保将WebBrowser实例的数量限制在一个合理的范围内,比如3-4个。你可以使用SemaphoreSlim.WaitAsync来实现这一点(SO上有很多使用示例)。另外需要注意的是,所有的WebBrowser实例共享同一个HTTP会话(包括cookie)。 - noseratio - open to work
我已经向您的 Gmail 发送了一封电子邮件,附带了我的类作为附件。由于没有进一步的讨论,我不确定我的单独问题是什么。在我的电子邮件中,我建议如果您能够提供任何帮助,我们可以在此处发布最终的问题和解决方案,并链接到它。您是一个 TPL 大神。 - Dan Cook
2
我使用了Stephen Toub在这里的示例:http://blogs.msdn.com/b/pfxteam/archive/2011/11/10/10235834.aspx来尝试这个任务。基于Rx的解决方案也非常有趣。实际上,每个并行的MaxDegreeOfParallelism方式似乎工作得很好。已经解析了15000条记录,同时处理20条记录,并且它还没有崩溃(但是?)。让我们关闭这个问题,但如果您感兴趣,请随时回复我的电子邮件。致敬 - Dan Cook
1
@Noseratio,你的代码在控制台应用程序中运行正常。我在一个WinForms应用程序中尝试了几乎完全相同的代码,但是我只得到了“URL:http://example.com”的输出。是什么导致了WinForms上的问题?区别在于:我为您的代码创建了一个新类-“Program2”。我向窗体添加了一个按钮,该按钮调用“Program2.Start(new string [1]);”。在我的类中,“Start”替换了您的“Main”。我还尝试了另一种版本,在其中使用默认的“public partial class Form1:Form”,将您的“Main”替换为包含您的“Main”主体的“button1_Click”。没有成功。有什么想法? - Jacob Quisenberry
显示剩余8条评论

4

我怀疑在另一个线程上运行处理循环可能不会很顺利,因为WebBrowser是一个托管ActiveX控件的UI组件。

当您编写TAP over EAP wrappers时,建议使用扩展方法使代码更加简洁:

public static Task<string> NavigateAsync(this WebBrowser @this, string url)
{
  var tcs = new TaskCompletionSource<string>();
  WebBrowserDocumentCompletedEventHandler subscription = null;
  subscription = (_, args) =>
  {
    @this.DocumentCompleted -= subscription;
    tcs.TrySetResult(args.Url.ToString());
  };
  @this.DocumentCompleted += subscription;
  @this.Navigate(url);
  return tcs.Task;
}

现在,您的代码可以轻松应用超时:
async Task<string> GetUrlAsync(string url)
{
  using (var wb = new WebBrowser())
  {
    var navigate = wb.NavigateAsync(url);
    var timeout = Task.Delay(TimeSpan.FromSeconds(5));
    var completed = await Task.WhenAny(navigate, timeout);
    if (completed == navigate)
      return await navigate;
    return null;
  }
}

可以直接使用以下形式:
private async Task<Uri> GetFinalUrlAsync(PortalMerchant portalMerchant)
{
  SetBrowserFeatureControl();
  if (string.IsNullOrEmpty(portalMerchant.Url))
    return null;
  var result = await GetUrlAsync(portalMerchant.Url);
  if (!String.IsNullOrEmpty(result))
    return new Uri(result);
  throw new Exception("Parsing Failed");
}

谢谢,我尝试了您的解决方案,但是Web浏览器必须在STA线程上使用,并且需要一个消息循环工作器(就像我的(Noseratio's)原始代码中一样)。我不知道如何将此因素纳入您的解决方案中。 - Dan Cook
我写的代码旨在从UI线程调用。可以创建一个单独的STA线程,但除非真的必要,否则我不会这样做。 - Stephen Cleary
WebBrowser必须在STA线程上运行,因为ActiveX的工作方式。非常感谢您的回答。对于不需要使用Web浏览器的人来说,这确实有效,我已经测试过了。 - Dan Cook
我知道这很老了,但我无法避免静态任务<string> NavigateAsync代码中的“不是所有代码路径都返回值”的问题。为了避免另一个问题,我在带有“TrySetResult”的行上添加了'ToString()'。感谢您的帮助。 - Jerome
@Stephen:谢谢你,Stephen。这是我在你的博客 https://blog.stephencleary.com/2012/02/async-and-await.html 上读到的内容。 - Jerome
抱歉,我不得不使用答案(我知道这不是)但我不知道如何直接回答与您在此处输入相关的问题。 - Jerome

-1

我正在尝试从Noseratio的解决方案中获益,并遵循Stephen Cleary的建议。

这是我更新的代码,包括来自Stephen的代码和来自Noseratio关于AJAX提示的代码。

第一部分:由Stephen建议的Task NavigateAsync

public static Task<string> NavigateAsync(this WebBrowser @this, string url)
{
  var tcs = new TaskCompletionSource<string>();
  WebBrowserDocumentCompletedEventHandler subscription = null;
  subscription = (_, args) =>
  {
    @this.DocumentCompleted -= subscription;
    tcs.TrySetResult(args.Url.ToString());
  };
  @this.DocumentCompleted += subscription;
  @this.Navigate(url);
  return tcs.Task;
}

第二部分:新的 Task NavAjaxAsync 用于运行基于 Noseratio 代码的 AJAX 提示
public static async Task<string> NavAjaxAsync(this WebBrowser @this)
{
  // get the root element
  var documentElement = @this.Document.GetElementsByTagName("html")[0];

  // poll the current HTML for changes asynchronosly
  var html = documentElement.OuterHtml;

  while (true)
  {
    // wait asynchronously
    await Task.Delay(POLL_DELAY);

    // continue polling if the WebBrowser is still busy
    if (webBrowser.IsBusy)
      continue;

    var htmlNow = documentElement.OuterHtml;
    if (html == htmlNow)
      break; // no changes detected, end the poll loop

    html = htmlNow;
  }

  return @this.Document.Url.ToString();
}

第三部分:一个新的Task NavAndAjaxAsync来获取导航和AJAX
public static async Task NavAndAjaxAsync(this WebBrowser @this, string url)
{
  await @this.NavigateAsync(url);
  await @this.NavAjaxAsync();
}

第四部分也是最后一部分:Stephen更新的Task GetUrlAsync,其中包含Noseratio的AJAX代码。
async Task<string> GetUrlAsync(string url)
{
  using (var wb = new WebBrowser())
  {
    var navigate = wb.NavAndAjaxAsync(url);
    var timeout = Task.Delay(TimeSpan.FromSeconds(5));
    var completed = await Task.WhenAny(navigate, timeout);
    if (completed == navigate)
      return await navigate;
    return null;
  }
}

我想知道这是否是正确的方法。

抱歉,伙计们,这个问题无法在单个评论中解决(或者至少我不知道如何解决)。 - Jerome

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