quinta-feira, 18 de agosto de 2011

Enumeração de listas com Tasks

Imaginemos que num código já existente temos um método que itera uma lista e que efectua uma qualquer operação com o seu conteúdo. Neste exemplo simplesmente imprime o próprio item:

        static void AsyncPrint<T>(List<T> list)
        {
            foreach (var i in list)
            {
                Console.WriteLine("-> :{0}", i);
            }
        }

Então alguém decide que a operação que é efectuada sobre cada item pode ser feita em paralelo e nem sequer é necessário aguardar pelo resultado. O código é alterado para:

        static void AsyncPrint1<T>(List<T> list)
        {
            foreach (var i in list)
            {
                Task.Factory.StartNew(() => { Console.WriteLine("-> :{0}", i); });
            }
        }

A questão que se coloca é: o resultado é o mesmo?
Ou melhor ainda se o programador que altera este código é fã de lambda expressions e reescreve desta forma:

        static void AsyncPrint2<T>(List<T> list)
        {
            list.ForEach((i) =>
            {
                Task.Factory.StartNew(() => { Console.WriteLine("-> :{0}", i); });
            });
        }

O output dos métodos AsyncPrint, AsyncPrint1 e AsyncPrint2 é semelhante? Não é? Como é possível? O que está a acontecer?
Ora, o método AsyncPrint2 obtém o mesmo resultado do AsyncPrint, mas em AsyncPrint1 obtemos um resultado diferente. Tipicamente, é impresso sempre o mesmo item ou alguns são impressos em duplicado e faltam outros. Porquê? Ao iniciar uma Task dentro da enumeração tradicional do foreach e como a acção concreta está definida num delegate (ou lambda expression) contido no método, a variável i que é acedida no corpo do delegate é uma closure que revela o seu valor no momento que é acedida. Quando a lista é iterada, é dada ordem de início de uma tarefa para o tratamento do item actual e avança-se para o próximo, mas a iteração da lista continua enquanto a Thread se mantiver em execução. Ou seja, não é garantido que a tarefa se inicie no momento da sua criação e muito provavelmente, quando esta se iniciar, a enumeração já avançou e o item actual já não é o mesmo.
No método AsyncPrint2 o comportamento é o esperado porque a variável i é um parâmetro de entrada do delegate que é invocado na implementação do método List<T>.forEach(), não é uma closure.
Então, como se pode fazer para manter a utilização da expressão clássica do foreach? Simples, basta que se deixe de utilizar a variável de iteração como closure:

        static void AsyncPrint3<T>(List<T> list)
        {
            foreach (var i in list)
            {
                var x = i;
                Task.Factory.StartNew(() => { Console.WriteLine("-> :{0}", x); });
            }
        }

Já agora, também se pode utilizar a Task Parallel Library, assim:

        static void AsyncPrint4<T>(List<T> list)
        {
            list.AsParallel().ForAll((i) =>
            {
                 Console.WriteLine("-> :{0}", i); 
            });
        }

No entanto, é necessário ter em atenção que aqui o comportamento será ligeiramente diferente: neste caso o método só termina quando a operação terminar em todos os itens da lista enquanto que nos exemplos anteriores isso não acontece.

Para aprender mais sobre closures recomendo este artigo do Jon Skeet: The Beauty of Closures