Sources.RU Magazine Поиск по журналу
 

TopList

Асинхронное программирование

Автор: juice

В предлагаемой статье пойдет разговор о написании Concurrent кода. Зачастую программисты, не имеющие достаточной теоретической базы, не в состоянии аргументировать принимаемые ими при разработке приложений решения. Их знания строятся на некоторых предположениях, домыслах и, естественно, на шишках, которые им уже пришлось набить. Однако все перечисленное не гарантирует понимания и фундаментальных знаний. Обычно, найдя некоторый обходной путь, программист превозносит это решение как единственно правильное и безоговорочно верное. Никто не идеален, и каждый может легко признать нехватку знаний в различных областях, тем не менее, обмен опытом можно и нужно произвести.

Многие слышали об АРМ (Asynchronous Programming Model) – одном из известных паттернов для реализации выполнения асинхронного кода на платформе .NET. Существует и альтернативная модель асинхронного выполнения, которая построена на использовании событийной модели. События используются для уведомления вызывающего кода. В самом простом случае это код, который выполняется в созданном программистом дополнительном потоке и по окончании выполнения уведомляет родительский поток об окончании своей работы, вследствие чего родительский поток имеет возможность воспользоваться результатами труда дочернего.

Правильного ответа на вопрос «какую именно модель стоит использовать при разработке приложений» не существует. Каждая модель имеет свои преимущества и недостатки. Впрочем, есть некое обобщенное правило. Если клиентом кода является библиотека, то обычно используется APM, если же графический интерфейс, то правильным выбором будет реализация асинхронных вызовов, основанных на событийной модели.

Типичным представителем рассматриваемой модели является общеизвестный BackgroundWorker класс, сэкономивший программистам большое количество времени, но обсуждать этот класс неинтересно, так как он хорошо известен любому читателю, заинтересованному в обсуждаемой теме. Вместо этого в статье рассказывается, как правильно писать код, реализуя классы, подобные классу BackgroundWorker. Проблемы синхронизации cross-thread и GUI операций обсуждаются вскользь, основное время посвящено написанию реально полезного кода, который продемонстрирует на практике использование описываемого паттерна и поможет лучше понять, что может предложить.NET Framework и как нужно стараться писать свой код.

Для начала рассмотрим типичную задачу: инициализацию формы данными, получение которых требует значительного времени.

Довольно часто встречается ситуация «подвисания» некоторых приложений и форм в момент выполнения запрашиваемых пользователем действий. Чтобы понять, почему так происходит, достаточно понимать, что GUI в Windows всегда выполняется в единственном ассоциированном с ним потоке. Каждая презентационная технология в рамках .NET реализует специальный внутренний менеджер, который неустанно проверяет и пресекает все попытки работать с GUI из других потоков. Можно иметь много потоков, но взаимодействовать с GUI можно только в одном единственном. Бесполезно рассуждать, почему это именно так и кто виноват, это данность, с которой следует смириться. Можно только заметить, что это не всегда плохо, и такая модель предлагает некоторые бонусы. Так вот, как только приложение выполняет код, который занимает какое-либо значимое время из потока GUI, GUI перестает обрабатывать все поступающие ему сообщения, выполнение потока приостанавливается до конца выполнения кода, что приводит к отсутствию перерисовки, «подвисанию» и появлению различных сопутствующих явлений.

Далеко за примером ходить не надо: каждый хоть раз пользовался мастером Data Connection – Add Connection в студийном Server Explorer. После попытки выбрать сервер из комбобокса, окно уходит в «даун», а если попытаться подергать хидер, появляется устрашающая надпись о том, что приложение не отвечает на запросы. Конечно, это не так, рано или поздно нужный код выполнится, можно будет выбрать нужный сервер из списка. Причина происходящего очевидна. В момент опроса сети на наличие экземпляров было «заморожено» выполнение потока GUI, и приложение в это время не было способно сделать, что-либо полезное. Это плохой способ проектирования. Гораздо лучше стараться держать поток GUI свободным. Идеальный GUI выполняет в GUI потоке исключительно обращения к GUI элементам (чтение, запись) и синхронизацию, весь остальной код выполняется в параллельных потоках. Мир не идеален, но можно постараться сделать так, что бы он хотя бы чуть-чуть приблизился к этому.

Чтобы решить вышеприведенную проблему, достаточно тот код, который опрашивает сеть, выполнить в независимом от GUI потоке, а потом сообщить основному потоку, что код выполнился, передать результат его выполнения кода в основной поток, произвести синхронизацию и обновить данные в контролах. Звучит устрашающе, на в предложенной статье подробно исследуется первая частью проблемы, а известная статья PIL поможет разобраться с синхронизацией и обновлением. Все действительно просто.

Синхронное API определяется следующим образом:

 public interface ISqlServerNetworkManager
    {
        IList EnumAvailableSqlServers();
    }

Легко понять, что должна делать реализация данного интерфейса. Она должна возвращать список объектов, каждый из которых опишет доступный сервер в сети. Ниже для полноты картины приводится реализация NetworkInstanceDescriptor:

 public class NetworkInstanceDesceriptor
    {
        public NetworkInstanceDesceriptor(){}

        public NetworkInstanceDesceriptor(string name, string server, string instance)
        {
            Name = name;
            Server = server;
            Instance = instance;
        }

        public string Instance { get; set; }
        public string Server { get; set; }
        public string Name { get; set; }

        public override string ToString()
        {
            return String.Format("Name: {0} Server: {1} - Instance: {2}", Name, Server, Instance ?? "Default");
        }
    }

.NET предлагает несколько способов выполнения перечисления серверов. В предлагаемой ниже имплементации используются возможности SMO. Для того, что бы код компилировался, нужно подключить в проект сборку Microsoft.SqlServer.Smo и добавить соответствующее пространство имен:

public class SqlServerNetworkManager : ISqlServerNetworkManager
    {
        public IList EnumAvailableSqlServers()
        {
            var descriptors = new List();
            using (DataTable table = SmoApplication.EnumAvailableSqlServers())
            {
                foreach (DataRow row in table.Rows)
                    descriptors.Add(BuildNetworkDescriptor(row));
            }

            return descriptors;
        }

        private static NetworkInstanceDesceriptor BuildNetworkDescriptor(DataRow row)
        {
            return new NetworkInstanceDesceriptor(row["Name"] as string,
                                                  row["Server"] as string,
                                                  row["Instance"] as string);
        }
     }

В реализации синхронного API нет ничего из области Rocket Science, после выполнения запроса полученный DataTable обрабатывается построчно, а на выходе генерируется нужная коллекция.
Программисты, реализовывавшие вышеописанный визард, об этом не подумали. Они просто вызвали синхронный метод, установили курсор в часики, после получения результата, заполнили комбобокс, сбросили курсор в дефолт и двинулись дальше. Так написано 90% всего программного обеспечения.

Для добавления асинхронного метода потребуется, прежде всего, сам метод, который приведет к асинхронному вызову, а также событие, подписавшись на которое, клиент класса получит уведомление о том, что код выполнен, и сможет через аргументы события получить результат его выполнения. Метод void EnumAvailableSqlServersAsync() ничего прямо не возвращает клиенту потому тип возвращаемого значения void. Синхронный метод не требовал аргументов, потому и в асинхронном варианте они не требуются. Окончание Async в названии метода – правило хорошего тона. Программист, который будет пользоваться API, сможет сразу определить, что вызов метода приводит к асинхронной операции, а следовательно, вполне возможно, ему потребуется подписаться на событие, чтобы получить результат работы метода. Отметим, что в этом методе будет создаваться дополнительный поток, в котором и будет вызываться синхронная реализация.
.NET Framework предоставляет стандартный базовый класс аргумента, который должен использоваться в данном случае, это AsyncCompletedEventArgs. В нем определены два важных свойства: Canceled и Error. Первое позволяет обработчику узнать, что выполнение потока было прервано по желанию пользователя, второе предоставляет возможность коду, выполняемому в дочернем потоке, сообщить родительскому об исключении, произошедшем при его выполнении.
Это очень важно.
Проведем один небольшой эксперимент.

static void Main(string[] args)
        {
            try
            {
                ThrowExceptionMethod(null);
            }
            catch
            {
                
            }

            Console.ReadLine();
        }

        private static void ThrowExceptionMethod(object o)
        {
            throw new Exception("Child thread exception");
        }

Исключение поймано, и программа продолжает работать. Можно корректно обработать пойманное исключение, а затем восстановить нормальную работу приложения (если ошибка не была фатальной) или завершить приложение в мягкой для него форме.
Пусть ThrowExceptionMethod вызывается из другого потока:

static void Main(string[] args)
        {
            try
            {
                ThreadPool.QueueUserWorkItem(ThrowExceptionMethod);
            }
            catch(Exception ex)
            {
                
            }

            Console.ReadLine();
        }

        private static void ThrowExceptionMethod(object o)
        {
            throw new Exception("Child thread exception");
        }

Catch не возымел никакого действия. Необработанное в порожденных потоках исключение приводит к фатальному сбою приложения. В .NET, как видно из примера, существуют механизмы, которые мешают в главном потоке обработать исключения дочернего. Единственное, что остается – подписаться на AppDomain.UnhandledException, но это может оказаться полезным разве что для логирования. Восстановить работу приложения все равно не удастся. Следовательно, обработка исключений в дочернем потоке является обязательной, если приложение не собирается аварийно завершаться из-за ошибки.
Ниже приводится пример обработки исключения в дочернем потоке.

class Program
    {
        static void Main(string[] args)
        {
            try
            {
                ThreadPool.QueueUserWorkItem(ThrowExceptionMethod);
            }
            catch(Exception ex)
            {
                
            }

            Console.ReadLine();
        }

        private static void ThrowExceptionMethod(object o)
        {
            try
            {
                throw new Exception("Child thread exception");
            }
            catch(Exception ex)
            {
                
            }
        }
    }

Приложение продолжает работать. Следует, впрочем, помнить, что просто ловить исключения нехорошо, их всегда нужно обрабатывать или бросать дальше, если они произошли в коде библиотеки.
Возвращаясь к AsyncCompletedEventArgs и к его свойству Error, можно сказать, что оно предоставляет удобный способ обработать исключение дочерним потоком и выслать его «посылкой» главному. Если нужно передавать главному потоку не только обработанное исключение, но и результат выполнения метода, этот класс следует унаследовать. Хорошо, если наследник будет более универсальным и сможет использоваться с различными типами возвращаемых значений при написании любого ассинхронного API, хотя решение и без Generic имеет смысл во многих случаях разработки специализированных библиотеки.

 public class EnumAvailableSqlServersEventArgs : AsyncCompletedEventArgs 
    {
        public EnumAvailableSqlServersEventArgs(T result, Exception error, bool cancelled, object userState)
            : this(error, cancelled, userState)
        {
            Result = result;
        }

        public EnumAvailableSqlServersEventArgs(Exception error, bool cancelled, object userState) : base(error, cancelled, userState)
        {
        }

        public T Result { get; set; }
    }

Расширить наш интерфейс теперь выглядит так:

public interface ISqlServerNetworkManager
    {
        event EventHandler>> EnumAvailableSqlServersComplited;
        IList EnumAvailableSqlServers();
        void EnumAvailableSqlServersAsync();
    }

Был добавлен метод и событие, подписавшись на которое, клиент кода сможет корректно обработать полученный результат. Для генерации события воспользуемся стандартным паттерном:

 protected void InvokeEnumAvailableSqlServersComplited(EnumAvailableSqlServersEventArgs> e)
        {
            EventHandler>> complited = EnumAvailableSqlServersComplited;
            if (complited != null)
                complited(this, e);
        }

Метод, запускающий код в отдельном потоке, выглядит следующим образом:

 public void EnumAvailableSqlServersAsync()
        {
            ThreadPool.QueueUserWorkItem(o =>
                                     {
                                         try
                                         {
                                             IList result = EnumAvailableSqlServers();
                                             InvokeEnumAvailableSqlServersComplited(new EnumAvailableSqlServersEventArgs>(result,
                                             null,
                                             false,
                                             null));
                                         }
                                         catch (Exception ex)
                                         {
                                             InvokeEnumAvailableSqlServersComplited(new EnumAvailableSqlServersEventArgs>(ex, false,
                                                                                                              null));
                                          }
                                      });
        }

Вместо создания потока вручную, был использован ThreadPool. Вместо явной реализации метода, удовлетворяющего WaitCallback, было использовано лямбда выражение, вследствие чего код стал не только короче, но и понятнее. Реализация через ThreadPool прямолинейна, но имеет ряд ограничений. Впрочем, обычно этого достаточно для приложения. Главное, что это просто и безопасно, а изменить реализацию на более сложную всегда можно при надобности. Отдельно стоит отметить, что реализация возможности множественных вызовов асинхронного метода здесь никак не обрабатывается, хотя было бы не плохо это предусмотреть.
Можно было реализовать такую возможность через управляющий флаг (IsBusy или InProgress), что позволило бы клиенту класса проверять возможность повторного обращения, а код, обнаруживая множественные вызовы, мог бы бросать исключение. Другой способ менее тривиален и состоит в том, что бы передавать в Async метод дополнительный параметр, уникально идентифицирующий вызов (object tasked). При генерации события для клиента класса этот taskId мог бы передаваться обработчику. Дополнительный плюс состоит в том, что данный идентификатор можно использовать при реализации возможности отмены конкретных "тасков".
Стоит обратить внимание, что исключения, которые могут возникнуть при выполнении кода в порождаемом пулом потоке, перехватываются. В случае возникновения такого исключения генерируется событие, и в конструктор аргумента передается само исключение, если же код выполнился нормально, генерируется событие, которое позволит вызывающему коду получить результат выполнения.
Ниже приводится консольное приложение, которое использует реализованный класс.

static void Main(string[] args)
        {
            var manager = new SqlServerNetworkManager();
            manager.EnumAvailableSqlServersComplited += ManagerEnumAvailableSqlServersComplited;
            manager.EnumAvailableSqlServersAsync();
            Console.ReadLine();
        }

        static void ManagerEnumAvailableSqlServersComplited(object sender, EnumAvailableSqlServersEventArgs> e)
        {
            if (e.Error == null)
            {
                foreach (var instance in e.Result)
                    Console.WriteLine(instance);
            }
            else
            {
                Console.WriteLine("Пользователь видит окно с ошибкой");
            }
        }

Здесь происходит подписка на событие и вызов асинхронный метода. Обработчик получает результат и выводит его на консоль.

Используя такую модель при работе с GUI, нельзя забывать синхронизировать доступ. Можно синхронизировать код с помощью cross-thread в GUI. Зачастую этот подход обоснован, но он не единственен. Например, выше был упомянут компонент BackgroundWorker, который умеет работать с GUI и синхронизировать операции его обновления. Кроме того, можно ввести автоматический маршалинг кода, выполняемого асинхронно, в поток GUI. Это, безусловно, усложняет код, но делает работу с классом более удобной. .NET Framework позволяет использовать удобные механизмы для маршалинга кода с потока в поток.
Базовую функциональность, связанную с маршалингом, предоставляет класс SynchronizationContext. Его наследники обычно реализуют возможность синхронизации вызовов для конкретной технологии. Такие классы имеются в WPF, WindowsForms, APS.NET. Наследование используется для обеспечения возможности работать с контекстом через базовый класс, не задумываясь при написании компонентов о конкретной технологии. Самыми важными методами контекста синхронизации являются Post и Send. Именно они производят маршализацию. Базовый класс имеет наивные реализации. Например, Post использует ThreadPool, а Send – обычный синхронный вызов делегата, зато эти методы объявлены как виртуальные, что и позволяет классам наследникам определять свои собственные механизмы маршалинга. Ниже показана реализация методов в классе SynchronizationContext:

 public virtual void Post(SendOrPostCallback d, object state)
    {
        ThreadPool.QueueUserWorkItem(new WaitCallback(d.Invoke), state);
    }

Для вызова метода через делегат используется пул потоков.

public virtual void Send(SendOrPostCallback d, object state)
    {
        d(state);
    }

Идет прямой вызов посредством делегата.

Ниже показана реализация методов в классе WindowsFormsSynchronizationContext, наследнике контекста синхронизации.

public override void Post(SendOrPostCallback d, object state)
    {
        if (this.controlToSendTo != null)
        {
            this.controlToSendTo.BeginInvoke(d, new object[] { state });
        }
    }

    public override void Send(SendOrPostCallback d, object state)
    {
        Thread destinationThread = this.DestinationThread;
        if ((destinationThread == null) || !destinationThread.IsAlive)
        {
            throw new InvalidAsynchronousStateException(SR.GetString("ThreadNoLongerValid"));
        }
        if (this.controlToSendTo != null)
        {
            this.controlToSendTo.Invoke(d, new object[] { state });
        }
    }

Метод Post пользуется тем, что контекст имеет ссылку на Control из Windows Forms, а каждый контрол предоставляет метод BeginInvoke, позволяющий запустить код в потоке GUI. В методе Send ситуация не сложнее: когда контекст создается, он получает ссылку на поток this.DestinationThread = Thread.CurrentThread; это код из конструктора. После проверки валидности потока, применяется тот же прием что и в методе Post, но в синхронном исполнении. Ниже представлен полный код конструктора, понятный без дополнительных пояснений:

public WindowsFormsSynchronizationContext()
{
    this.DestinationThread = Thread.CurrentThread;
    Application.ThreadContext context = Application.ThreadContext.FromCurrent();
    if (context != null)
    {
        this.controlToSendTo = context.MarshalingControl;
    }
}

Статическое свойство Current возвращает ссылку на контекст.

Существует вспомогательный класс AsyncOperatonManager. Он имеет свойство SynchronizatoinContext, возвращающее ссылку на текущий контекст синхронизации, а также фабричный метод CreateCommand, который возвращает объект AsyncOperation. Свойство SynchronizatoinContext, проверяет, существует ли установленный контекст синхронизации, и если существует, возращает его, если же нет, возвращает дефолтную реализация SynchronizatoinContext. Это свойство просто экономит несколько лишних строчек кода при обращении к контексту. Например, не нужно проверять его на null. Метод CreateCommand создает команду, которая имеет ссылку на текущий контекст синхронизации. Она позволяет сохранить некое пользовательское состояние, кроме того, она имеет и собственное состояние (выполнена или нет). Когда команда выполнена, она может сообщить об этом контексту, что бы он корректно освободил необходимые ресурсы. Алгоритм следующий: посредством вызова AsyncOperatonManager?CreateOpperation, создается команда; передается, если необходимо, пользовательский объект, служащий параметром асинхронного метода; затем AsyncOperatonManager устанавливает команде текущий контекст, устанавливает команде поле _complited в false и вызывает метод контекста OperationStarted. Дефолтный контекст имеет пустую реализацию, зато наследники, такие как WindowsFormsSynchronizationContext, могут определять собственную логику по отслеживанию операции маршалинга (хотя за исключением контекста для ASP.NET, никто этого не делает).
Каждая команда имеет методы Post и Send, которые делегируют всю нужную работу ассоциированному контексту, проверяя, что делегат не null, а собственное состояние команды установлено в «не выполнено». Команда в состоянии «выполнена» более не пригодна к использованию. Это сделано для того, чтобы корректно освобождать ресурсы. Еще один полезный метод – PostOperationComplited, он тоже делегирует вызов через Post, но дополнительно устанавливает команду в состояние «выполнена».

Ниже рассматривается применения на практике вышеизложенных теоретических сведений. Объявляется переменную private AsyncOperation _currentOperation;

Пусть клиентский код не допускает несколько параллельных асинхронных вызовов.
В EnumAvailableSqlServersAsync инициализируется команда. Вот тело метода:

 _currentOperation = AsyncOperationManager.CreateOperation(null);


            ThreadPool.QueueUserWorkItem(obj =>
             {
                 Exception execption = null;
                 IList result = null;

                 try
                 {
                     result = EnumAvailableSqlServers();
                 }
                 catch (Exception ex)
                 {
                     execption = ex;
                 }
                 finally
                 {
                     CombineResults(result, execption);
                 }
             });

Вместо непосредственной генерации события об окончании выполнения, вызывается CombineResult для сбора EnumAvailableSqlServersEventArgs> и выполнения маршализации в поток GUI с помощью команды, вызывая для ней PostOperationCompleted. Ниже приведена реализация этого метода:

private void CombineResults(IList result, Exception exception)
        {
            var args = new EnumAvailableSqlServersEventArgs>(result, exception, false, null);
            _currentOperation.PostOperationCompleted(_onCompletedDelegate, args);
        }

Первым параметром метода PostOperationCompleted является делегат SendOrPostCallback. До вызова метода, назначенного делегату, мы находимся в коде, вызываемом через пул, после же этого (после маршализации), оказываемся в потоке GUI, если, конечно, код выполнялся в WinForms или WPF.

Делегат предварительно нужно объявить в программе:

private readonly SendOrPostCallback _onCompletedDelegate;M

и в конструкторе класса проинициализировать его:

public SqlServerNetworkManager()
        {
            _onCompletedDelegate = new SendOrPostCallback(AsyncCallCompleted); 
        }

Для инициализации используется следующий метод:

private void AsyncCallCompleted(object operationState)
        {
            var e = operationState as EnumAvailableSqlServersEventArgs>;
            InvokeEnumAvailableSqlServersComplited(e);
        }

Внутри этого метода мы уже в потоке GUI, если код выполнялся в WinForms или WPF. То есть, уже имеется маршализированное значение, и остается только сгенерировать соответствующее событие для уведомления клиента о том, что операция выполнена.

Последний штрих – добавление свойства IsBusy.

private bool _isBusy;

        public bool IsBusy
        {
            get { return _isBusy; }
            protected set { _isBusy = value; }
        }

Когда вызывается EnumAvailableSqlServersAsync, нужно проверить, что код не занят:

if (IsBusy)
                throw new InvalidOperationException();

            IsBusy = true;

и установить IsBusy в true, а после выполнения маршализации можно снова разрешить использование метода EnumAvailableSqlServersAsync:

private void CombineResults(IList result, Exception exception)
        {
            var args = new EnumAvailableSqlServersEventArgs>(result, exception, false, null);
            _currentOperation.PostOperationCompleted(_onCompletedDelegate, args);
            IsBusy = false;
        }

В результате получается код, позволяющий клиенту вызывать его асинхронно, причем результат работы этого кода не требует никакой синхронизации с GUI. Это удобно.

Ниже приводится код с небольшими комментариями:

 public class SqlServerNetworkManager : ISqlServerNetworkManager
    {
        private bool _isBusy;
        private readonly SendOrPostCallback _onCompletedDelegate;

        // используется для маршализации обращений между потоками
        private AsyncOperation _currentOperation;

        // подписавшись на событие, клиент получит результат. Он сможет обработать результат или просто узнать об ошибке
        public event EventHandler>> EnumAvailableSqlServersComplited;

        public SqlServerNetworkManager()
        {
            _onCompletedDelegate = new SendOrPostCallback(AsyncCallCompleted); 
        }

        public bool IsBusy
        {
            get { return _isBusy; }
            protected set { _isBusy = value; }
        }

        private void AsyncCallCompleted(object operationState)
        {
            // Находясь здесь после синхронизации, можно без проблем обращаться к GUI.
            var e = operationState as EnumAvailableSqlServersEventArgs>;
            InvokeEnumAvailableSqlServersComplited(e);
        }

        // Синхронная реализация
        public IList EnumAvailableSqlServers()
        {
            var desceriptors = new List();
            using (DataTable table = SmoApplication.EnumAvailableSqlServers(false))
            {
                foreach (DataRow row in table.Rows)
                    desceriptors.Add(BuildNetworkDescriptor(row));
            }

            return desceriptors;
        }

        // Асинхронная реализация
        public void EnumAvailableSqlServersAsync()
        {
            // Нельзя обращаться к коду до конца выполнения маршализации, 
            // клиенту предоставляется свойство IsBusy для того, 
            // чтобы он мог проверить возможность вызова
            if (IsBusy)
                throw new InvalidOperationException();

            IsBusy = true;

            _currentOperation = AsyncOperationManager.CreateOperation(null);

            ThreadPool.QueueUserWorkItem(obj =>
             {
                 Exception execption = null;
                 IList result = null;

                 try
                 {
                     result = EnumAvailableSqlServers();
                 }
                 catch (Exception ex)
                 {
                     execption = ex;
                 }
                 finally
                 {
                     CombineResults(result, execption);
                 }
             });
           
        }

        // результат работы кода собирается в аргумент события и маршализируется между потоками
        private void CombineResults(IList result, Exception exception)
        {
            var args = new EnumAvailableSqlServersEventArgs>(result, exception, false, null);
            _currentOperation.PostOperationCompleted(_onCompletedDelegate, args);
            IsBusy = false;
        }

        private static NetworkInstanceDesceriptor BuildNetworkDescriptor(DataRow row)
        {
            return new NetworkInstanceDesceriptor(row["Name"] as string,
                                                  row["Server"] as string,
                                                  row["Instance"] as string);
        }

        protected void InvokeEnumAvailableSqlServersComplited(EnumAvailableSqlServersEventArgs> e)
        {
            EventHandler>> complited = EnumAvailableSqlServersComplited;
            if (complited != null)
                complited(this, e);
        }
    }


 Design by Шишкин Алексей (Лёха)  ©2004-2008 by sources.ru