Ждущие потоки и зачем они могут понадобиться на практике
Аннотация
Речь пойдет о решении не типовой задачи программирования на языке Objective-C с использованием Cocoa Touch. Будут затронуты некоторые общие моменты многопоточного программирования, разобран вопрос создания потока, подобного главному потоку приложения. Всё это происходит на примере исправления довольно критического бага класса NSURLConnection. В конце статьи приведён весь исходный код, который вы можете свободно использовать в своих проектах.
Вступление
Зачем это нужно
Проблема заключается в том, что на платформе iPhone OS 3.x, в классе NSURLConnection нельзя проставить таймаут соединения. (подробнее можно почитать, например, тут) Есть пути решения с асинхронным вызовом и отменой выполнения запроса с помощью таймера, который эмулирует таймаут, но что делать, если необходим именно синхронный доступ? Одним из возможных решений является создание обёртки вокруг асинхронного вызова и реализации таким образом аналога метода sendSynchronousRequ
est:returningResponse
:error: класса NSURLConnection. Этим и займёмся, а попутно научимся делать кое-какие интересные вещи.
Архитектура решения
То, как будет работать наш аналог, схематично изображено на рисунке:
Давайте посмотрим, что здесь происходит. Пусть в процессе выполнения нашего основного потока, решающего какую-нибудь задачу, возникает необходимость получить синхронно какие-нибудь данные с удалённого сервера. Буквой «А» обозначено время, когда основной поток вызывает наш аналог метода sendSynchronousRequest. После этого происходит следующее:
- Блокируется основной поток исполнения (мы ведь реализуем синхронный метод).
- Создается новый поток («рабочий поток»).
- Внутри рабочего потока создается активный объект класса NSURLConnection. Вместе с ним создается таймер, установленный на необходимый интервал ожидания.
- Созданные объекты активируются: начинают загружаться данные и тикать время, отведенное на процесс.
- Если срабатывает таймер, а запрос еще не закончен, то нужно прервать процесс закачки данных. С другой стороны, если заканчивается запрос, то нужно остановить таймер.
- В точке «B» информация об успешности загрузки и её результаты передается основному потоку, после чего он разблокируется и продолжает свою работу.
Всё было бы хорошо и очень просто, если бы не одно «НО».
«Ждущие» потоки
Подводный камень
Когда я впервые посмотрел на этот план, мне всё показалось лёгким и понятным. Но на самом деле тут есть некоторая проблема с рабочим потоком. Дело в том, что мы возлагаем на него нестандартные обязанности: он должен сидеть и ждать сообщений от объекта класса NSURLConnection. Но как так сделать? И можно ли?
На самом деле, можно. Аналогичной функциональностью обладает главный поток приложения, который ожидает событий интерфейса, чтобы их обработать и опять вернуться в состояние «висения». Однако создать что-нибудь подобное самому оказывается не такой простой задачей. И прежде чем продолжить, стоит сказать несколько слов про «внутренности» потоков; это поможет нам с вами лучше понимать друг друга в дальнейшем.
Вскрытие потоков: что внутри?
Традиционно многопоточное программирование считается очень сложным для восприятия и проектирования. У этой статьи нет цели посвятить читателя в магию синхронизации или раскрыть некоторые основные подходы к построению многопоточных архитектур, однако нам понадобится «широкий» взгляд на сами потоки.
Попробуем представить себе, как выглядит главный поток приложения (на рисунке он изображен ввиде зубастого Pacman’а). У него есть очередь, состоящая из блоков команд, которые он по одному достаёт и исполняет. Если очередь пустеет, поток блокируется и ждёт пополнения. Новый блок кода может добавиться по сотне причин: может быть, какое-либо сообщение пришлёт операционная система, а может пользователь ткнет в кнопку, и тогда в очередь добавится обработчик этого события.
Обычные потоки, которые создаются парой строчек кода, выглядят в точности также, но с одним «но»: они выполняют только один блок кода, по завершении которого сразу же завершаются. И этот блок — тело самого потока, которое мы с вами пишем при создании.
Таким образом такой поток просто не пригоден для использования в качестве целевого для асинхронных вызовов методов (которые как раз и занимаются тем, что добавляют в очередь потока блоки кода). И такой расклад нас с вами, естественно, не устраивает, потому что асинхронная работа NSURLConnection построена по шаблону активного объекта, который в свою очередь основывается на асинхронных вызовах методов. А значит все обработчики событий, вызванные объектом NSURLConnection (вспоминаем нашу архитектуру), просто не будут выполнены. А должны бы.
Cocoa Framework спешит на помощь
Итак, для реализации нашей задумки нужно написать поток, способный ждать и выполнять блоки кода, которые ему откуда-либо посылаются. Задача выглядит низкоуровневой и, увы, достаточно скудно освещена в сети (или мало индексирована Гуглом?..).
Но оказывается, всё не так грустно, как кажется с первого взгляда. По крайней мере, до этой сферы дотянулся своими лапами замечательный Cocoa Framework, к помощи которого мы и прибегнем.
Единственным классом, который нам понадобится, будет NSRunLoop. Мы сделаем следующее: напишем поток с таким телом, которое будет блокировать себя, ожидая блоки кода в очереди, а потом исполнять их, как только они появляются. Звучит сложно? Без паники. Это гораздо проще, чем кажется. Давайте просто посмотрим на код, а потом уже будем понимать, что в нём творится.
Итак, три значимые строчки тела потока перед вами:
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop addPort:[NSMachPort port]
forMode:NSDefaultRunLoopMode];
while ([runLoop runMode:NSDefaultRunLoopMode
beforeDate:[NSDate distantFuture]]);
Что же тут происходит? Давайте разбираться!
Вдумчивый взгляд
Начнём с начала.
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
Тут не сложно: мы просто получаем объект, с помощью которого будем совершать доступ к очереди потока.
Для того, чтобы понять вторую строчку, давайте сначала разберём цикл.
while ([runLoop runMode:NSDefaultRunLoopMode
beforeDate:[NSDate distantFuture]]);
Вот это уже интереснее. Словами русского языка этот цикл можно описать так: «пока происходит что-то непонятное, проверяй ещё и ещё, что это непонятное продолжает происходить». На деле вызывается всего один метод runMode:beforeDate:. Метод является блокирующим и ждёт до определённого времени (время указано вторым его аргументом) пришествия в очередь потока какого-нибудь кода. После этого он этот код исполняет и возвращает true.
Однако метод иногда возвращает false. Это может происходить по двум причинам:
- Произошло что-то специфическое, из-за чего «цикл выполнения не может быть запущен» (дословный перевод из документации)
- Когда runLoop не находит потенциальных источников событий.
Если первый случай от нас на прямую не зависит, то обеспечение потока необходимыми источниками — целиком наша обязанность. С нею с успехом справляется вторая строчка кода
[runLoop addPort:[NSMachPort port]
forMode:NSDefaultRunLoopMode];
Теперь, я надеюсь, вам уже почти всё понятно. Запутанными должны оставаться только «режимы» (те самые параметры forMode: и.т.д.), на которые я до настоящего момента везде закрывал глаза. Исполнение в том или ином режиме — это выполнение типов блоков кода в очереди, специфичных для этого режима. Остальные блоки игнорируются.
Хоть сколько-нибудь подробное описание «режимов» и всего того, что к ним прилагается, не входит в цель этой статьи. Это достаточно большая тема, достойная отдельного изложения. Подробнее про это и многое другое можно прочитать здесь.
Итоги
Промежуточный результат и его использование
На основании всего вышесказанного вы теперь можете сами легко реализовать «ждущие» потоки. Однако я вполне допускаю, что вы устали вчитываться и понимать, или у Вас нет времени, или, в конце концов, Вам просто не нравятся мои рисунки. В таком случае, в самом конце, вы можете найти мой код, применяющий знания, изложенные в этой статье, на практике. Пользоваться этим классом очень легко:
KGIdleThreadManager* backgroundThread = [[KGIdleThreadManager alloc] init];
Теперь в вашем распоряжении есть «ждущий» поток, на который можно посылать сообщения для выполнения:
[self performSelector:@selector(foo)
onThread:backgroundThread.thread
withObject:nil
waitUntilDone:false];
Важно помнить, что объект не уничтожится, если вы не вызовете метод stop, который остановит «ждущий» поток.
[backgroundThread stop]; [backgroundThread release];
Возвращение к истокам
Напомню, с чего всё начиналось: мы собирались реализовать свой аналог для метода sendSynchronousRequest. Для этого мы разработали архитектуру, решили большую техническую проблему, стоящую на пути. Всё что остаётся — взять клавиатуру и воплотить идею в жизнь. Мою реализацию вы можете найти в конце статьи. Самое сложное в ней — синхронизация двух потоков с помощью класса NSCondition, поэтому я не буду останавливаться на её коде. Рассмотрим только использование.
Класс предоставляет всего два метода. Рассмотрим их по очереди:
- (NSData *)send:(NSURLRequest *)request
timeout:(NSTimeInterval)_timeout
error:(NSError **)error;
Этот метод посылает созданный запрос и возвращает байты, присланные сервером в ответ. Если произошло что-либо непредвиденное, метод возвращает nil.
- (void)stop;
Этот метод прерывает процесс скачивания. Так как сам процесс синхронный, то его можно и нужно вызывать из другого потока.
Заключение
Хотелось бы сказать, что описание функционирования потоков как PacMan’ов с очередями из блоков кода является очень грубым и изложено здесь для наглядности.
Термин «ждущего» потока введён автором и вряд ли является хоть сколько распространенным.
Кроме того, я буду рад любой критике, советам и замечаниям. С большой благодарностью приму более простое решение проблемы создания «ждущих» потоков или другой способ синхронной связи с веб-сервером с возможностью выставления timeout-а.
Спасибо за внимание!
Файлы статьи
- Исходный код классов KGIdleThreadManager и KGURLRequester
[runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]
Может лучше было бы конечную дату поставить? Чтобы можно было поток сбросить в любое время.
Можно, но идеально, чтобы цикл прокручивался как можно реже и только по необходимости. Поэтому в практической реализации используется флаг и хитренькая реализация метода stop. Можете посмотреть сами в исходнике KGIdleThreadManager
Так ведь он не сможет проверить состояние переменной running до того как выйдет из runLoop, значит «backgroundThread» будет ждать событий пока программа не будет прибита системой.
Я бы решил задачу как-то так: gist.github.com/514947
«Так ведь он не сможет проверить состояние переменно running..» Действительно, не сможет. Именно поэтому функция stop делает две вещи: ставит running в false и искусственно генерирует событие, чтобы цикл прокрутился. Таким образом остановка произойдёт фактически мгновенно.
Ваше решение совершает лишние действия, однако неплохо на практике.
Действительно, я как-то забыл о том факте, что «[runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]» возвращает управления после получения _одного_ события.