Índice de la sección dedicada a .NET (en el Guille) Cómo... en .NET

Crear una aplicación que utiliza múltiples hilos (threads) en Visual Basic .NET y C#

Ejemplo para contar las palabras contenidas en ficheros

Código para Visual Basic.NET (VB.NET) Código para C Sharp (C#)

Publicado el 19/Ene/2004
Actualizado el 21/Abr/2004


Esto que te voy a mostrar aquí es una actualización de el artículo que escribí en marzo del 2001, ejemplo en el cual usé la versión BETA1 de Visual Basic .NET.

Aunque los conceptos explicados anteriormente todavía siguen siendo válidos, (lo único que cambia es la forma de usar ciertas funciones de .NET), te lo pongo aquí aparte para que no te compliques mucho la vida, además de que también he cambiado parte del código para que sea más seguro, ya que al trabajar con múltiples hilos (threads) el problema que se nos puede presentar es que varios hilos intenten acceder al mismo código a un mismo tiempo (o casi) y al final se produzcan errores o resultados que no son los previstos.

¿Qué hace el código de este ejemplo?

El ejemplo sigue siendo el mismo:
Recorrer unos directorios en busca de ficheros con la extensión indicada y extraer cada una de las palabras que contiene, acumular esas palabras en una colección, de forma que al final nos muestre cada una de las palabras halladas y nos indique cuantas veces se ha repetido.

 

 

Las palabras se almacenan en una colección personalizada llamada cPalabras que almacena objetos del tipo cPalabra. Dicha colección personalizada utiliza una una colección del tipo Hashtable. Como sabrás, las colecciones de este tipo siempre almacenan un valor tipo DictionaryEntry con un valor para la clave (Key) y el valor propiamente dicho (Value). En la clave almacenamos el ID de cada palabra, que en realidad es la propia palabra hallada, en el valor almacenamos el objeto del tipo cPalabra, que, en esta versión del "buscador" de palabras, nos dirá también cuantas veces se ha encontrado dicha palabra.

Aquí tienes una captura del programa después de haber analizado el directorio con la copia local de lo que hay en mi Web:

Como puedes comprobar lo que más abunda son tags de HTML, por eso hay tantas p, a, etc., ya que eso realmente son: <p>, <a...>, etc.

 

Veamos "paso a paso" cada una de las cosas que hace el código, y de camino te iré explicando qué es lo que hago y dónde puede estar el problema.

Primero te diré las clases que se utilizan y para que sirve cada una de ellas:

Clase Para que sirve
cPalabra   Se deriva de cContenido, que a su vez se deriva de cID, de forma que tenga las propiedades de cada una de estas clases... esto no es realmente necesario hacerlo así, ya que se podía haber creado la clase directamente con esas propiedades, pero...
De esta clase se utiliza el ID, el Contenido y el total de veces que está esa palabra.
cPalabras Colección para almacenar objetos del tipo cPalabra.
Internamente usa una colección del tipo Hashtable.
cProcesarFicheroThread Una clase para procesar cada directorio y desglosar cada una de las palabras de los ficheros hallados que cumplan la especificación.
Internamente utiliza una colección compartida del tipo cPalabras, para almacenar todas las palabras halladas. Al estar compartida se usará por todas las instancias de esta clase.
Tiene dos campos (cuasi-propiedades) compartidos para almacenar el total de directorios y ficheros procesados.
Tiene otros tres campos para indicar el directorio a procesar, la extensión de los ficheros y el thread asignado para procesar ese directorio.
El método ProcesarDir será usado por cada hilo. Debido a que un procedimiento usado en un thread no puede recibir parámetros, se utilizan las propiedades sDir y sExt para saber "qué" buscar.
Esta clase produce dos eventos que nos informará de cada directorio y fichero que se está procesando.
cBuscarPalabrasEnFicheros Esta clase es la que se encarga de hacer todo el trabajo.
Tiene un método público: Procesar que será el que se encargue de crear los objetos del tipo cProcesarFicheroThread y asignar los threads que se usarán.
Realmente utiliza un array del tipo cProcesarFicheroThread, cada elemento de dicho array se encargará de procesar un directorio diferente. En la versión de C#, en lugar de usar un array, utilizo una colección del tipo ArrayList. Es posible que en el código definitivo incluido en el fichero zip se use también una colección.
Esta clase produce tres eventos para informarnos de lo que va sucediendo.
fBuscarPalabras Es el formulario usado para que el usuario indique qué es lo que hay que buscar y dónde buscarlo... Una vez procesados, se mostrarán en un ListView las palabras halladas.

Veamos ahora, con un poco más de detalle, que hace cada una de esas clases, realmente no te voy a "pormenorizar" cada línea de código, simplemente resaltaré lo que crea conveniente resaltar.

Desde el formulario llamamos al método Procesar de la clase cBuscarPalabrasEnFicheros, ésta clase produce tres eventos para informarnos de lo que va sucediendo:
ProcesandoDirectorio, se produce cada vez que se empieza a procesar un nuevo directorio.
ProcesandoFichero, idem pero para cada fichero procesado.
FicherosProcesados, se produce cuando se ha terminado de procesar todo lo que había que procesar.
En un momento te explico cómo se sincronizan estos eventos con los de las clases que cada thread utiliza.

El método Procesar se encarga de crear una (o varias) nueva instancia de la clase cProcesarFicheroThread que será la encargada de procesar cada uno de los directorios indicados.
Si se procesa sólo un directorio, se usa sólo una clase del tipo cProcesarFicheroThread.
Pero si son varios los directorios a procesar (porque se ha seleccionado "con subdirs" en el formulario), se creará un array de dicha clase y un método recursivo, el cual será llamado para uno de los directorios a procesar.

En esta clase, he creado dos métodos, los cuales se usarán para interceptar los eventos de las clases que se encargan de procesar los directorios en hilos diferentes. De forma que cada vez que la clase "buscadora" produzca un evento, se llamen a estos métodos. Desde estos métodos se producirán los eventos que notificarán al formulario que algo ha sucedido.

Veamos el código de esos dos métodos y después veremos cómo "engancharlos" con los de cada clase:

' Estos métodos se usarán para producir los eventos de esta clase.
Protected Sub OnProcesandoFichero(ByVal sMsg As String)
    ' Este evento se produce cuando se está procesando un fichero
    ' por uno de los threads.
    ' Desde aquí producimos nuestro evento a la aplicación.
    RaiseEvent ProcesandoFichero(sMsg)
End Sub
Protected Sub OnProcesandoDirectorio(ByVal sMsg As String)
    ' Este evento se produce cuando se está procesando un directorio
    ' por uno de los threads.
    ' Desde aquí producimos nuestro evento a la aplicación.
    RaiseEvent ProcesandoDirectorio(sMsg)
End Sub

OnProcesandoFichero se encargará de producir el evento ProcesandoFichero y OnProcesandoDirectorio será el encargado de producir el otro: ProcesandoDirectorio. Esos dos eventos serán interceptados por el formulario para poder ir mostrando al usuario la información de qué es lo que está haciendo nuestra aplicación.

Ahora veremos cómo indicarle a cada una de las clases que se ejecutarán en hilos independientes cómo llamar a estos métodos.
La clase cBuscarPalabrasEnFicheros utiliza un array, declarado a nivel de módulo, llamado mProcesarFic del tipo cProcesarFicheroThread, que será el que realmente se utilice para cada uno de los hilos que se van a crear. En el código de C# en lugar de usar un array se utiliza una colección del tipo ArrayList y es posible que en el código completo de VB incluido en el zip se incluya la versión del código de Visual Basic que use la colección en lugar del array.
Cada vez que se vaya a procesar un directorio crearemos una nueva instancia de la clase, le indicaremos qué directorio debe procesar, la extensión de los ficheros que queremos procesar y qué método debe usarse para notificarnos de que algo ha ocurrido. También crearemos un nuevo hilo y le indicaremos qué método debe usarse en ese hilo. El método es uno de la propia clase que instanciamos y lo asignamos al campo esteThread de la propia clase. De esta forma no hace falta mantener un array con los threads y otro con las clases que procesará cada directorio. No se si esta forma tendrá menos sobrecarga que usando dos arrays independientes, (tal como hacía en el código de la beta1), pero creo que así será más claro y todo estará "más encapsulado".
Veamos el código que hace todas estas asignaciones:

' redimensionamos el array
nThread += 1
ReDim Preserve mProcesarFic(nThread - 1)
' creamos una nueva instancia de la clase
' a la que le pasamos la colección "compartida" de palabras
mProcesarFic(nThread - 1) = New Guille.Clases.cProcesarFicheroThread(lasPalabras)
' asignamos los "manejadores" de eventos
AddHandler mProcesarFic(nThread - 1).ProcesandoFichero, AddressOf Me.OnProcesandoFichero
AddHandler mProcesarFic(nThread - 1).ProcesandoDirectorio, AddressOf Me.OnProcesandoDirectorio
' asignamos el directorio y la extensión a procesar
mProcesarFic(nThread - 1).sDir = sDir
mProcesarFic(nThread - 1).sExt = sExt
' creamos un nuevo thread
mProcesarFic(nThread - 1).esteThread = New Thread(New ThreadStart(AddressOf mProcesarFic(nThread - 1).ProcesarDir))
' iniciamos el nuevo thread
mProcesarFic(nThread - 1).esteThread.Start()

La variable nThread tendrá el total de hilos que se están ejecutando, inicialmente valdrá cero. Realmente nos servirá para poder crear un nuevo elemento del array de la clase que procesará cada directorio.
Después de crear una nueva instancia de la clase, la añadimos al array y le asignamos los procedimientos que se usarán para los dos eventos que dicha clase producirá.
A continuación asignamos el directorio y la extensión de los ficheros que se procesarán. Esto es necesario, porque el método que se encarga de procesar cada directorio, el usado con el hilo, no puede recibir parámetros, por tanto de alguna forma debe saber dónde buscar...
Finalmente creamos un nuevo hilo al que le indicamos qué método debe usar e iniciamos dicho hilo.

Nota:
En la asignación/creación del nuevo hilo he usado New ThreadStart que es el delegado usado por el constructor de la clase Thread. En Visual Basic .NET realmente no hace falta hacerlo así, ya que al usar AddessOf el compilador "sabe" que nuestra intención es usar un delegado, por tanto dicha asignación se podría hacer de esta otra forma:
New Thread(AddressOf mProcesarFic(nThread - 1).ProcesarDir)

Desde este momento, será el propio sistema el que se encargará de que los threads se vayan ejecutando.

El método ProcesarDir de la clase cProcesarFicheroThread se encargará de recorrer todos los ficheros del directorio indicado, (que tengan la extensión especificada), leerá el contenido de cada uno de ellos y desglosará dicho contenido en palabras, (o números). Para ese desglose se utiliza el método Split de la clase String, de forma que se le pase como parámetro un array de los caracteres que queremos usar como separador. Realmente lo que hacemos es crear un array con cada una de las palabras que contenga el fichero y usamos dicho array para comprobar si la palabra está o no en la colección.
Para crear ese array, se usa la cadena sSep, la cual una vez convertida a un array de tipo Char, podemos usar con el método Split.
Esto se hace en el método desglosar, tal como se muestra a continuación:

Private Sub desglosar(ByVal sText As String, ByVal sFic As String)
    ' Desglosar en palabras individuales el contenido del parámetro
    '
    ' Estos signos se considerarán separadores de palabras
    Dim sSep As String = ".,;: ()<>[]{}¿?¡!/\'=+*-%&$|#@" & ChrW(34) & ChrW(9) & ChrW(10) & ChrW(13)
    ' crear un array con cada una de las palabras
    Dim palabras() As String = sText.Split(sSep.ToCharArray)
    Dim tPalabra As Guille.Clases.cPalabra
    Dim i As Integer
    '
    For i = 0 To palabras.Length - 1
        Dim s As String = palabras(i)
        ' sólo si no es una cadena vacía
        If s <> "" Then
            If lasPalabras.Exists(s) = False Then
                tPalabra = New Guille.Clases.cPalabra
                tPalabra.ID = s
                tPalabra.Veces = 1
                ' el fichero en el que inicialmente apareció
                tPalabra.Contenido = sFic
                lasPalabras.Add(tPalabra)
            Else
                ' Incrementar el número de esta palabra
                'lasPalabras(s).Veces += 1
                lasPalabras(s).IncVeces()
            End If
        End If
    Next
    '
End Sub

En el bucle comprobamos si la palabra está o no en la colección.
En caso de que no esté, se crea un nuevo objeto del tipo cPalabra y se añade a la colección.
En ese método Add de la colección hay que tener precaución. Ya que se utiliza este código para saber si se debe o no añadir la palabra a la colección:

If m_ht.ContainsKey(tPalabra.ID) = False Then
    m_ht.Add(tPalabra.ID, tPalabra)
End If

Realmente no hace falta hacer esa comprobación extra, pero... ese no sería precisamente el "punto problemático". El problema se podría presentar en el siguiente caso, cuando se hace esta asignación en la colección interna:
m_ht.Add(tPalabra.ID, tPalabra)
Si el ID indicado ya existe en la colección, se producirá una excepción.
Seguramente te dirás: Vale... pero si se comprueba antes que sólo se ejecute esa línea si no existe ese ID... ¿cómo se va a producir una excepción?
Imagínate que después de hacer esa comprobación, (y la correspondiente llamada al método Add de la colección interna), el sistema cambia de hilo y precisamente el hilo anterior se interrumpió justo después de comprobar que el ID no estaba en la colección, pero no llegó a añadirlo... lo que ocurrirá es que al querer añadir un elemento que ya existe, se producirá una excepción... echando al traste todo lo que hemos hecho...

No se si has entendido este punto. Te lo aclaro mejor para que no te queden dudas:
Supongamos que tenemos dos hilos y que cada uno de estos hilos está comprobando la misma palabra.
El primero ejecuta el If m_ht.ContainsKey(..., pero justo en ese momento, el sistema operativo congela ese hilo y pasa a otro hilo el control de la ejecución, el cual "por casualidad", ejecuta esa misma comparación, pero el sistema le da un poco de más tiempo, y llega a ejecutar también lo que hay después del If, añadiendo esa palabra a la colección. Justo después de añadir la palabra a la colección, el sistema vuelve a darle el control al primer hilo, como resulta que la palabra que se comprobaba en ese primer hilo no estaba en la colección, se cumple la condición y se va a ejecutar el método Add. Pero ese método fallará, porque el segundo hilo ya añadió la palabra a la colección.

Por tanto, para evitar esta situación, tenemos que decirle al sistema operativo:
¡Déjame hacer mi trabajo completo y tendremos menos problemas!
Vale, mu bonito, pero... ¿cómo se lo decimos?
Usando la instrucción SyncLock en Visual Basic .NET o lock en C#.

Esto es lo que dice la ayuda de Visual Studio .NET sobre esta instrucción:
La instrucción SyncLock garantiza que múltiples subprocesos no ejecuten las mismas instrucciones al mismo tiempo. Cuando el subproceso llega al bloque SyncLock, evalúa la expresión y mantiene esta exclusividad hasta que obtenga un bloqueo en el objeto devuelto por la expresión. Esto impide que una expresión cambie valores durante la ejecución de varios subprocesos, lo que puede proporcionar resultados inesperados del código.

En nuestro caso, se utiliza la propia clase como expresión a evaluar, ya que dicha expresión debe ser un tipo por referencia.

El código quedaría de esta forma:

Public Overridable Sub Add(ByVal tPalabra As cPalabra)
    ' Añadir un nuevo elemento a la colección
    '
    ' bloquear este método por si se usan Threads           (17/Ene/04)
    SyncLock Me.GetType
        If m_ht.ContainsKey(tPalabra.ID) = False Then
            m_ht.Add(tPalabra.ID, tPalabra)
        End If
    End SyncLock
End Sub

Pero lo mismo nos puede ocurrir si resulta que la palabra ya está en la colección.
En ese caso, se usa este código:

lasPalabras(s).IncVeces()

Aunque es posible que pienses que la sincronización habría que hacerla en el método IncVeces de la clase cPalabra, el problema no estará ahí.
Ya que realmente esa llamada al método IncVeces se hace por medio de lasPalabras(s), que no es ni más ni menos que una llamada al bloque Get de la propiedad Item de la colección cPalabras. Cuantas vueltas da nuestro código ¿verdad?

Para comprenderlo mejor, voy a sustituir esa llamada al método IncVeces() por el que de verdad ve el compilador de Visual Basic .NET:

lasPalabras.Item(s).IncVeces()

Esto es así porque la propiedad Item es la propiedad predeterminada (el indizador si el código fuese en C#).

Por tanto, antes de llamar al método IncVeces se llama al bloque Get de la propiedad "predeterminada" Item; esa propiedad devuelve un objeto del tipo cPalabra que tenga el ID (o clave) indicado en el parámetro. El código usado en esa propiedad (que es de solo lectura), es el siguiente:

Default Public Overridable ReadOnly Property Item(ByVal sIndex As String) As cPalabra
    Get
        ' Método predeterminado
        Dim tPalabra As cPalabra
        '
        If m_ht.ContainsKey(sIndex) Then
            Return CType(m_ht(sIndex), cPalabra)
        Else
            ' Creamos una nuevo objeto
            tPalabra = New cPalabra
            tPalabra.ID = sIndex
            ' lo añadimos a la colección
            m_ht.Add(sIndex, tPalabra)
            '
            Return tPalabra
        End If
    End Get
End Property

Y si has comprendido el problema que se podía producir en el método Add, aquí tenemos algo parecido, ya que se comprueba si dicha clave está en la colección, en caso de que esté, se devuelve el objeto correspondiente, y si no existe, se añade a la colección.
Debes tener presente que en el código que estoy utilizando nunca se cumplirá que no esté dicha clave en la colección, y por tanto nunca se producirá una excepción desde la propiedad Item.
Pero en otras circunstancias, es posible que quieras usar dicha propiedad sin hacer la comprobación "extra" de que ya existe, en cuyo caso, tendrás que "sincronizar" también dicha propiedad. Por tanto deberías saber cómo usar el bloque SyncLock en una propiedad, ya que en un método en más o menos evidente, pero no tanto en una propiedad, ya que en el caso de las propiedades, la sincronización hay que hacerla en cada uno de los bloques Get y Set.
Por tanto el código anterior, una vez "sincronizado" quedaría así:

Default Public Overridable ReadOnly Property Item(ByVal sIndex As String) As cPalabra
    Get
        ' bloquear esta propiedad por si se usan Threads    (17/Ene/04)
        SyncLock Me.GetType
            ' Método predeterminado
            Dim tPalabra As cPalabra
            '
            If m_ht.ContainsKey(sIndex) Then
                Return CType(m_ht(sIndex), cPalabra)
            Else
                ' Creamos una nuevo objeto
                tPalabra = New cPalabra
                tPalabra.ID = sIndex
                ' lo añadimos a la colección
                m_ht.Add(sIndex, tPalabra)
                '
                Return tPalabra
            End If
        End SyncLock
    End Get
End Property

 

Este es el procedimiento que será usado por cada hilo. Desde aquí se llamará al método desglosar para que compruebe cada una de las palabras que hay en el fichero examinado.

Public Sub ProcesarDir()
    '------------------------------------------------------------------
    ' Procesar los ficheros del path y extensión indicadas.
    ' Convertido en Sub para usar con Thread                (25/Mar/01)
    ' 
    ' Modificaciones para adaptarlo a la versión definitiva (17/Ene/04)
    '------------------------------------------------------------------
    '
    Dim tFiles() As String
    Dim i, n As Integer
    Dim s As String
    Dim sr As System.IO.StreamReader
    '
    ' Asigna a tFiles un array con los nombres de los ficheros,
    ' path incluido
    tFiles = Directory.GetFiles(sDir, sExt)
    '
    'Debug.WriteLine(sDir)
    '
    ' Examinar el contenido de cada fichero y trocearlo en palabras
    '
    n = tFiles.Length - 1
    '
    ' este evento se irá produciendo de forma independiente al orden
    ' en el que se procesen los ficheros...
    ' y realmente no producirá el efecto deseado:
    ' que se vaya mostrando cada directorio conforme se procesan los ficheros
    RaiseEvent ProcesandoDirectorio(String.Format("Dir: {0}, con {1} ficheros.", sDir, n + 1))
    ' un respiro para que se actualice la información (no funciona siempre)
    'esteThread.Sleep(10)
    TotalDirectorios += 1
    '
    For i = 0 To n
        RaiseEvent ProcesandoFichero(" Procesando: " & (i + 1).ToString & " / " & (n + 1).ToString & " " & Path.GetFileName(tFiles(i)) & " ...")
        ' si se pone aquí, si que se irá mostrando
        'RaiseEvent ProcesandoDirectorio(String.Format("Dir: {0}, con {1} ficheros.", sDir, n + 1))
        'esteThread.Sleep(50)
        '
        ' Abrir el fichero usando la codificación estándard de Windows
        sr = New StreamReader(tFiles(i), System.Text.Encoding.Default)
        s = sr.ReadToEnd
        sr.Close()
        '
        ' Buscar cada una de las palabras del fichero
        desglosar(s, tFiles(i))
    Next
    '
    TotalFicheros += tFiles.Length
End Sub

 

Para terminar, veamos cómo controlar que cada hilo ha terminado, ya que de alguna forma tenemos que saber cuando ha terminado todo para poder seguir con la ejecución normal.

Como te comentaba antes, dependiendo de que se procese uno o varios directorios, se usará un código u otro, pero en ambos casos, usaremos el método Procesar de la clase cBuscarPalabrasEnFicheros. Desde aquí se decidirá si procesar un directorio o varios.
En caso de que sean varios los directorios a procesar (se habrá marcado la opción conSubDirs del formulario), se llamará al método "recursivo" procesarSubDir, en el cual se examina un directorio y si contiene más directorios, se llamará nuevamente a este mismo método. Para que lo comprendas, te muestro el código:

Private Sub procesarSubDir(ByVal sDir As String, _
                           Optional ByVal sExt As String = "*.*")
    '------------------------------------------------------------------
    ' Este procedimiento será llamado de forma recursiva para procesar
    ' cada uno de los directorios.
    '------------------------------------------------------------------
    Dim tDir As String
    Dim tSubDirs() As String
    '
    tSubDirs = Directory.GetDirectories(sDir)
    '
    'Debug.WriteLine(sDir)
    '
    ' Crear un thread para cada directorio a procesar
    nThread += 1
    ReDim Preserve mProcesarFic(nThread - 1)
    mProcesarFic(nThread - 1) = New Guille.Clases.cProcesarFicheroThread(lasPalabras)
    ' asignar el manejador del evento                       (17/Ene/04)
    AddHandler mProcesarFic(nThread - 1).ProcesandoFichero, AddressOf Me.OnProcesandoFichero
    AddHandler mProcesarFic(nThread - 1).ProcesandoDirectorio, AddressOf Me.OnProcesandoDirectorio
    ' asignar los datos del directorio y la extensión a comprobar
    mProcesarFic(nThread - 1).sDir = sDir
    mProcesarFic(nThread - 1).sExt = sExt
    ' Los procedimientos a usar no deben aceptar parámetros
    mProcesarFic(nThread - 1).esteThread = New Thread(New ThreadStart(AddressOf mProcesarFic(nThread - 1).ProcesarDir))
    'mProcesarFic(nThread - 1).esteThread = New Thread(AddressOf mProcesarFic(nThread - 1).ProcesarDir)
    '
    ' Iniciar el thread
    mProcesarFic(nThread - 1).esteThread.Start()
    ' Llamada recursiva a este procedimiento para procesar los subdirectorios
    ' de cada directorio
    For Each tDir In tSubDirs
        ' Procesar todos los directorios de cada subdirectorio
        procesarSubDir(tDir, sExt)
    Next
End Sub

Aquí creamos un array con cada uno de los subdirectorios del directorio procesado. Se crea el hilo que procesará dicho directorio y se llama de forma recursiva a este mismo método usando cada uno de los subdirectorios:

' Llamada recursiva a este procedimiento para procesar los subdirectorios
' de cada directorio
For Each tDir In tSubDirs
    ' Procesar todos los directorios de cada subdirectorio
    procesarSubDir(tDir, sExt)
Next

 

Por fin, veremos el código completo del método Procesar, que es donde se comprobará si los hilos aún están ejecutándose o no:

Public Function Procesar(ByVal sDir As String, _
                        Optional ByVal sExt As String = "*.*", _
                        Optional ByVal conSubDir As Boolean = False _
                        ) As String
    '------------------------------------------------------------------
    ' Procesar los directorios del path indicado en sDir,
    ' buscando ficheros con la extensión sExt,
    ' y si se deben procesar los subdirectorios.
    '
    ' Si se quieren usar varias extensiones se podría hacer,
    ' pero hay que tener en cuenta que Directory.GetFiles
    ' no procesará varias extensiones separadas por ;
    ' Por tanto, habrá que hacer un bucle para cada extensión,
    ' pero eso se hará en el método ProcesarDir de la clase
    ' cProcesarFicheroThread.
    '------------------------------------------------------------------
    '
    nThread = 0
    ReDim mProcesarFic(0)
    '
    ' Comprobar si se van a procesar los subdirectorios
    If conSubDir Then
        procesarSubDir(sDir, sExt)
    Else
        ' redimensionamos el array
        nThread += 1
        ReDim Preserve mProcesarFic(nThread - 1)
        ' creamos una nueva instancia de la clase
        ' a la que le pasamos la colección "compartida" de palabras
        mProcesarFic(nThread - 1) = New Guille.Clases.cProcesarFicheroThread(lasPalabras)
        ' asignamos los "manejadores" de eventos
        AddHandler mProcesarFic(nThread - 1).ProcesandoFichero, AddressOf Me.OnProcesandoFichero
        AddHandler mProcesarFic(nThread - 1).ProcesandoDirectorio, AddressOf Me.OnProcesandoDirectorio
        ' asignamos el directorio y la extensión a procesar
        mProcesarFic(nThread - 1).sDir = sDir
        mProcesarFic(nThread - 1).sExt = sExt
        ' creamos un nuevo thread
        'mProcesarFic(nThread - 1).esteThread = New Thread(New ThreadStart(AddressOf mProcesarFic(nThread - 1).ProcesarDir))
        mProcesarFic(nThread - 1).esteThread = New Thread(AddressOf mProcesarFic(nThread - 1).ProcesarDir)
        ' iniciamos el nuevo thread
        mProcesarFic(nThread - 1).esteThread.Start()
    End If
    '
    ' Aquí llegará incluso antes de terminar todos los threads
    'Debug.WriteLine("Fin de Procesar todos los ficheros")
    '
    ' Comprobar si han terminado los threads
    Dim i, j As Integer
    Do
        j = 0
        For i = 0 To nThread - 1
            ' Comprobar si alguno de los Threads está "vivo"
            ' si es así, indicarlo para que continúe el bucle
            If mProcesarFic(i).esteThread.IsAlive Then
                j = 1
                Exit For
            End If
        Next
        ' Esto es necesario, para que todo siga funcionando
        System.Windows.Forms.Application.DoEvents()
    Loop While j = 1
    '
    'Debug.WriteLine("Han finalizado los threads")
    '
    ' Ahora podemos asignar el número de ficheros procesados
    i = cProcesarFicheroThread.TotalDirectorios
    j = cProcesarFicheroThread.TotalFicheros
    Dim sb As New System.Text.StringBuilder
    '
    sb.AppendFormat("Procesado: {0} dir., ", i)
    If j = 1 Then
        sb.AppendFormat("{0} fichero.", j)
    Else
        sb.AppendFormat("{0} ficheros.", j)
    End If
    RaiseEvent FicherosProcesados(sb.ToString)
    '
    ' Asignamos a cero el valor de total ficheros
    ' por si se vuelve a usar, para que no siga acumulando.
    cProcesarFicheroThread.TotalFicheros = 0
    cProcesarFicheroThread.TotalDirectorios = 0
    '
    Return sb.ToString
End Function

Para saber si han terminado todos los hilos de hacer su trabajo, recorremos el contenido del array y comprobamos si hay algún hilo "vivo", en cuyo caso... seguimos esperando.
Esto lo comprobamos mediante los bucles anidados:

' Comprobar si han terminado los threads
Dim i, j As Integer
Do
    j = 0
    For i = 0 To nThread - 1
        ' Comprobar si alguno de los Threads está "vivo"
        ' si es así, indicarlo para que continúe el bucle
        If mProcesarFic(i).esteThread.IsAlive Then
            j = 1
            Exit For
        End If
    Next
    ' Esto es necesario, para que todo siga funcionando
    System.Windows.Forms.Application.DoEvents()
Loop While j = 1

Una vez que ha terminado todos los threads, seguimos nuestro curso... y lanzamos un evento que le indique al formulario que ya hemos terminado:

RaiseEvent FicherosProcesados(sb.ToString)

En cuanto el formulario reciba esa notificación, se encargará de mostrar el contenido de las palabras en el ListView... o donde quiera... aunque en este ejemplo se muestra en un ListView.

 

Aunque no te lo muestro en esta página, el ejemplo que puedes bajar en el link que hay un poco más abajo, también tienes los siguientes "conceptos":

Espero que todo esto te sirva para aclararte un poco mejor cómo usar threads (hilos) con los lenguajes de .NET, que aunque en estos ejemplos haya usado código de Visual Basic .NET, en C# es casi igual de fácil... o complicado, según lo mires.

Como curiosidad, realmente no es una curiosidad sino el "sino" de los múltiples hilos, decirte que cuando ejecutes la aplicación verás que en la etiqueta que muestra los directorios que se van procesando, llega un momento en que ya no cambia, aunque si lo hace la etiqueta que muestra los ficheros procesados. Esto es así a causa de que los threads se van ejecutando sin ningún orden y van a "su aire".
El código que hace que se muestre esa información es el que se encarga de "lanzar" los eventos, estos eventos se lanzan en el método ProcesarDir de la clase cProcesarFicheroThread, el que informa de que se está procesando un directorio se lanza antes de empezar el bucle que recorre todos los ficheros de ese directorio, mientras que el que informa de cada fichero que se procesa, se lanza desde el bucle.
Aquí tienes parte de dicho código:

RaiseEvent ProcesandoDirectorio(String.Format("Dir: {0}, con {1} ficheros.", sDir, n + 1))
'
For i = 0 To n
    RaiseEvent ProcesandoFichero(" Procesando: " & (i + 1).ToString & " / " & (n + 1).ToString & " " & Path.GetFileName(tFiles(i)) & " ...")

Pero como te he dicho no consigue lo que quiere conseguir: Que se muestre el nombre del directorio procesado y se mantenga mientras se procesan los ficheros que contiene.
Y no lo loga, por lo que te he comentado antes, porque cada hilo va más o menos a su aire y no lleva ningún orden... o casi.

Si quieres que se vaya mostrando cada directorio que se procesa, mientras se procesan los ficheros, tendrás que cambiar el código para que quede de esta forma: (en el código del fichero ZIP está, pero comentado):

For i = 0 To n
    RaiseEvent ProcesandoFichero(" Procesando: " & (i + 1).ToString & " / " & (n + 1).ToString & " " & Path.GetFileName(tFiles(i)) & " ...")
    ' si se pone aquí, si que se irá mostrando
    RaiseEvent ProcesandoDirectorio(String.Format("Dir: {0}, con {1} ficheros.", sDir, n + 1))

 

¡Que lo disfrutes! (y si te sobran unos euros, ya sabes... PayPal al canto!)

Nos vemos.
Guillermo

Los ficheros con el código de ejemplo:
Código para Visual Basic .NET: WordCountThreadVB.zip 19.5 KB
Código para C#: WordCountThreadCS.zip 18.0 KB


Código para Visual Basic.NET (VB.NET)El código para VB .NET.

Es el mostrado a lo largo del artículo.

El código completo lo puedes conseguir en el fichero zip.

 


Código para C Sharp (C#)El código para C#.

Aquí te muestro los mismos trozos de código que hay en el texto.

El código completo lo puedes conseguir en el fichero zip.

 

Como "bono" especial, estas dos funciones que simulan las funciones homónimas (del mismo nombre) de Visual Basic:

private string getSetting(string appName, string section, string key, string sDefault)
{
    // Los datos de VB se guardan en:
    // HKEY_CURRENT_USER\Software\VB and VBA Program Settings
    RegistryKey rk = Registry.CurrentUser.OpenSubKey(@"Software\VB and VBA Program Settings\" + appName + "\\" + section);
    string s = sDefault;
    if( rk != null )
        s = (string)rk.GetValue(key);
    //
    return s;
}
private void saveSetting(string appName, string section, string key, string setting)
{
    // Los datos de VB se guardan en:
    // HKEY_CURRENT_USER\Software\VB and VBA Program Settings
    RegistryKey rk = Registry.CurrentUser.CreateSubKey(@"Software\VB and VBA Program Settings\" + appName + "\\" + section);
    rk.SetValue(key, setting); 
}

 

// Estos métodos se usarán para producir los eventos de esta clase.
protected void OnProcesandoFichero(string sMsg)
{
    // Este evento se produce cuando se está procesando un fichero
    // por uno de los threads.
    // Desde aquí producimos nuestro evento a la aplicación.
    if( ProcesandoFichero != null )
        ProcesandoFichero(sMsg);
}  
protected void OnProcesandoDirectorio(string sMsg)
{
    // Este evento se produce cuando se está procesando un directorio
    // por uno de los threads.
    // Desde aquí producimos nuestro evento a la aplicación.
    if( ProcesandoDirectorio != null )
        ProcesandoDirectorio(sMsg);
}

 

// Crear un thread para cada directorio a procesar
cProcesarFicheroThread oPF = new cProcesarFicheroThread(lasPalabras);
// asignar el manejador del evento                       (17/Ene/04)
oPF.ProcesandoFichero += new cProcesarFicheroThread.ProcesandoFicheroDelegate(this.OnProcesandoFichero);
oPF.ProcesandoDirectorio += new cProcesarFicheroThread.ProcesandoDirectorioDelegate(this.OnProcesandoDirectorio);
// asignar los datos del directorio y la extensión a comprobar
oPF.sDir = sDir;
oPF.sExt = sExt;
// Los procedimientos a usar no deben aceptar parámetros
oPF.esteThread = new Thread(new ThreadStart(oPF.ProcesarDir));
//
mProcesarFic.Add(oPF);  
//
// Iniciar el thread
oPF.esteThread.Start();

 

private void desglosar(string sText, string sFic){
    // Desglosar en palabras individuales el contenido del parámetro
    //
    // Estos signos se considerarán separadores de palabras
    string sSep  = @".,;: ()<>[]{}¿?¡!/\'=+*-%&$|#@" + "\"\t\n\r";"
    // crear un array con cada una de las palabras
    string[] palabras   = sText.Split(sSep.ToCharArray());
    cPalabra tPalabra;
    int i;
    //
    for(i = 0; i <= palabras.Length - 1; i++){
        string s  = palabras[i];
        // sólo si no es una cadena vacía
        if( s != "" ){
            if( lasPalabras.Exists(s) == false ){
                tPalabra = new Guille.Clases.cPalabra();
                tPalabra.ID = s;
                tPalabra.Veces = 1;
                // el fichero en el que inicialmente apareció
                tPalabra.Contenido = sFic;
                lasPalabras.Add(tPalabra);
            }else{
                // Incrementar el número de esta palabra
                lasPalabras[s].IncVeces();
            }
        }
    }
}

 

public virtual void Add(cPalabra tPalabra)
{
    // Añadir un nuevo elemento a la colección
    //
    // bloquear este método por si se usan Threads           (17/Ene/04)
    lock(this.GetType())
    {
        if( !m_ht.ContainsKey(tPalabra.ID) )
            m_ht.Add(tPalabra.ID, tPalabra);
    } // lock
}

 

public virtual cPalabra this[string sIndex]
{
    get{
        // bloquear esta propiedad por si se usan Threads    (17/Ene/04)
        lock(this.GetType())
        {
            cPalabra tPalabra;
            //
            if( m_ht.ContainsKey(sIndex) )
            {
                return (cPalabra)m_ht[sIndex];
            }
            else
            {
                // Creamos una nuevo objeto
                tPalabra = new cPalabra();
                tPalabra.ID = sIndex;
                // lo añadimos a la colección
                m_ht.Add(sIndex, tPalabra);
                //
                return tPalabra;
            }
        } // lock
    }
}

 

public void ProcesarDir()
{
    //------------------------------------------------------------------
    // Procesar los ficheros del path y extensión indicadas.
    // Convertido en Sub para usar con Thread                (25/Mar/01)
    //
    // Modificaciones para adaptarlo a la versión definitiva (17/Ene/04)
    //------------------------------------------------------------------
    //
    string[] tFiles;
    int i, n;
    string s;
    System.IO.StreamReader sr;
    //
    // Asigna a tFiles un array con los nombres de los ficheros,
    // path incluido
    tFiles = Directory.GetFiles(sDir, sExt);
    //
    //Debug.WriteLine(sDir)
    //
    // Examinar el contenido de cada fichero y trocearlo en palabras
    //
    n = tFiles.Length - 1;
    //
    // este evento se irá produciendo de forma independiente al orden
    // en el que se procesen los ficheros...
    // y realmente no producirá el efecto deseado:
    // que se vaya mostrando cada directorio conforme se procesan los ficheros
    if( ProcesandoDirectorio != null )
        ProcesandoDirectorio(String.Format("Dir: {0}, con {1} ficheros.", sDir, n + 1));
    // un respiro para que se actualice la información (no funciona siempre)
    //esteThread.Sleep(10)
    TotalDirectorios += 1;
    //
    for(i = 0; i <= n; i++){
        if( ProcesandoFichero != null )
            ProcesandoFichero(" Procesando: " + (i + 1).ToString() + " / " + (n + 1).ToString() + " " + Path.GetFileName(tFiles[i]) + " ...");
        // si se pone aquí, si que se irá mostrando
        //if( ProcesandoDirectorio != null )
        //    ProcesandoDirectorio(String.Format("Dir: {0}, con {1} ficheros.", sDir, n + 1));
        //esteThread.Sleep(50)
        //
        // Abrir el fichero usando la codificación estándard de Windows
        sr = new StreamReader(tFiles[i], System.Text.Encoding.Default);
        s = sr.ReadToEnd();
        sr.Close();
        //
        // Buscar cada una de las palabras del fichero
        desglosar(s, tFiles[i]);
    }
    //
    TotalFicheros += tFiles.Length;
}

 

private void procesarSubDir(string sDir)
{
    procesarSubDir(sDir, "*.*");
}
private void procesarSubDir(string sDir, string sExt)
{
    //------------------------------------------------------------------
    // Este procedimiento será llamado de forma recursiva para procesar
    // cada uno de los directorios.
    //------------------------------------------------------------------
    string[] tSubDirs;
    //
    tSubDirs = Directory.GetDirectories(sDir);
    //
    // Crear un thread para cada directorio a procesar
    cProcesarFicheroThread oPF = new cProcesarFicheroThread(lasPalabras);
    // asignar el manejador del evento                       (17/Ene/04)
    oPF.ProcesandoFichero += new cProcesarFicheroThread.ProcesandoFicheroDelegate(this.OnProcesandoFichero);
    oPF.ProcesandoDirectorio += new cProcesarFicheroThread.ProcesandoDirectorioDelegate(this.OnProcesandoDirectorio);
    // asignar los datos del directorio y la extensión a comprobar
    oPF.sDir = sDir;
    oPF.sExt = sExt;
    // Los procedimientos a usar no deben aceptar parámetros
    oPF.esteThread = new Thread(new ThreadStart(oPF.ProcesarDir));
    //
    mProcesarFic.Add(oPF);  
    //
    // Iniciar el thread
    oPF.esteThread.Start();
    //
    // Llamada recursiva a este procedimiento para procesar los subdirectorios
    // de cada directorio
    foreach(string tDir in tSubDirs)
        // Procesar todos los directorios de cada subdirectorio
        procesarSubDir(tDir, sExt);
}

 

public string Procesar(string sDir)
{
    return Procesar(sDir, "*.*", false);
}
public string Procesar(string  sDir, string sExt)
{
    return Procesar(sDir, sExt, false);
}
public string Procesar(string  sDir, string sExt, bool conSubDir)
{
    //------------------------------------------------------------------
    // Procesar los directorios del path indicado en sDir,
    // buscando ficheros con la extensión sExt,
    // y si se deben procesar los subdirectorios.
    //
    // Si se quieren usar varias extensiones se podría hacer,
    // pero hay que tener en cuenta que Directory.GetFiles
    // no procesará varias extensiones separadas por ;
    // Por tanto, habrá que hacer un bucle para cada extensión,
    // pero eso se hará en el método ProcesarDir de la clase
    // cProcesarFicheroThread.
    //------------------------------------------------------------------
    //
    mProcesarFic.Clear();
    //
    // Comprobar si se van a procesar los subdirectorios
    if( conSubDir )
        procesarSubDir(sDir, sExt);
    else{
        // Crear un thread para cada directorio a procesar
        cProcesarFicheroThread oPF = new cProcesarFicheroThread(lasPalabras);
        // asignar el manejador del evento                       (17/Ene/04)
        oPF.ProcesandoFichero += new cProcesarFicheroThread.ProcesandoFicheroDelegate(this.OnProcesandoFichero);
        oPF.ProcesandoDirectorio += new cProcesarFicheroThread.ProcesandoDirectorioDelegate(this.OnProcesandoDirectorio);
        // asignar los datos del directorio y la extensión a comprobar
        oPF.sDir = sDir;
        oPF.sExt = sExt;
        // Los procedimientos a usar no deben aceptar parámetros
        oPF.esteThread = new Thread(new ThreadStart(oPF.ProcesarDir));
        //
        mProcesarFic.Add(oPF); 
        // Iniciar el thread
        oPF.esteThread.Start();
    }
    //
    // Aquí llegará incluso antes de terminar todos los threads
    //Debug.WriteLine("Fin de Procesar todos los ficheros")
    //
    // Comprobar si han terminado los threads
    int i, j;
    do{
        j = 0;
        for(i = 0; i <= mProcesarFic.Count - 1; i++){
            // Comprobar si alguno de los Threads está "vivo"
            // si es así, indicarlo para que continúe el bucle
            if( ((cProcesarFicheroThread)mProcesarFic[i]).esteThread.IsAlive ){
                j = 1;
                break;
            }
        }
        // Esto es necesario, para que todo siga funcionando
        System.Windows.Forms.Application.DoEvents();
    }while( j == 1);
    //
    //Debug.WriteLine("Han finalizado los threads")
    //
    // Ahora podemos asignar el número de ficheros procesados
    i = cProcesarFicheroThread.TotalDirectorios;
    j = cProcesarFicheroThread.TotalFicheros;
    System.Text.StringBuilder sb = new System.Text.StringBuilder();
    //
    sb.AppendFormat("Procesado: {0} dir., ", i);
    if( j == 1 ){
        sb.AppendFormat("{0} fichero.", j);
    }else{
        sb.AppendFormat("{0} ficheros.", j);
    }
    // Producimos el evento... si está interceptado
    if( FicherosProcesados != null)
        FicherosProcesados(sb.ToString());
    //
    // Asignamos a cero el valor de total ficheros
    // por si se vuelve a usar, para que no siga acumulando.
    cProcesarFicheroThread.TotalFicheros = 0;
    cProcesarFicheroThread.TotalDirectorios = 0;
    //
    return sb.ToString();
}

 


la Luna del Guille o... el Guille que está en la Luna... tanto monta...