Implementing Multi Producer Multi Consumer Simple Thread Safe Class I am learning C# and in the process I am trying to implement a multi producer consumer thread safe class. Can you please code review and point me to any mistakes I have made and improvements I can make? I understand there would be libraries and other thread safe queue objects which would provide me the functionality which I am trying to implement, but the very reason I am doing this myself is to learn how threads and thread safe queues are coded and managed. My idea is to achieve maybe an under-optimized but correctly working code.
//simple thread safe many producer many consumer class
class ProducerConsumer&lt;Task&gt;
{
    public delegate void ConsumerCallback(Task t);

    protected bool keepRunning = false;
    protected Queue&lt;Task&gt; taskQueue = null;
    protected Thread[] consumerThreads = null;
    protected ConsumerCallback clientConsumerMethodCB = null;

    public ProducerConsumer(ConsumerCallback clientConsumerMethod, int consumerThreadsCount)
    {
        keepRunning = true;
        consumerThreads = new Thread[consumerThreadsCount];
        for (int i = 0; i &lt; consumerThreadsCount; i++)
        {
            consumerThreads[i] = new Thread(ConsumerThreadFunction);
        }

        for (int i = 0; i &lt; consumerThreadsCount; i++)
        {
            consumerThreads[i].Start();
        }

        taskQueue = new Queue&lt;Task&gt;();
        clientConsumerMethodCB = clientConsumerMethod;
    }

    public void Produce(Task t)
    {
        lock (taskQueue)
        {
            taskQueue.Enqueue(t);
        }

        Monitor.PulseAll(taskQueue);
    }

    protected void ConsumerThreadFunction()
    {
        Queue&lt;Task&gt; holdTaskQueueItem = new Queue&lt;Task&gt;();
        while (keepRunning)
        {
            while (taskQueue.Count &lt;= 0)
            {
                Monitor.Wait(taskQueue);
            }

            lock (taskQueue)
            {
                if(taskQueue.Count &gt; 0)
                {
                    holdTaskQueueItem.Enqueue(taskQueue.Dequeue());
                }
            }

            if(holdTaskQueueItem.Count &gt; 0)
            {
                clientConsumerMethodCB?.Invoke(holdTaskQueueItem.Dequeue());
            }
        }
    }
}
You have entered in the realm of concurrency. So there will be one or more functions which are competing to be executed. In other words there is no guarantee that your code will run in parallel.
If there is enough executor (CPU cores) then the runtime might schedule several threads to different cores. If there is not sufficient resource to be utilized then scheduler might use context switching.
Context switching will execute one portion of T1 and then it suspends T1 in order to avoid thread starvation (a single thread consumes all the available resources, while other threads remain idle). One portion of T2 is executed then it will be suspended. Now scheduler goes back to T1 to resume (hopefully finish) its current function.
These concepts are crucial to understand because these can cause strange behaviours for those that are used to program in a single threaded environment. Let me share with you a simple example:
Example

Let's suppose there is one item in the queue
Also let's assume that there are two consumers (T1 and T2) and they are both checking queue's length before they try to dequeue
So T1 and T2 are at the same line of where they are checking the queue's length
Scheduler suspends T1 after it successful checked the queue length
T2 dequeues the item from the queue
Scheduler resumes T1, which is in that belief that the queue has an item in it (which is not true, because T2 has already dequeued that)
T1 tries to dequeue item from the queue but it will fail because it could not get data from an empty queue

The problem here is that the check and the dequeue functionalities are not treated as atomic. If they were treated as atomic then T1 could not be suspended (by the scheduler or by the GC) between the two queue method calls.

Back to your code, there are several ways to improve it. (I'll respect your intent to learn so I will not suggest alternatives that are already done this)

Please do not use Task for the generic parameter name. It is a well-known structure in the .NET world. Please prefer either Job or Computation or what so ever.
As I can see you would like to use the keepRunning for signaling (either for cancellation or for completion). Unfortunately it is declared as false then set to true in the initialization phase and then it is never modified.
For locking the best practice is to introduce a private static readonly object lockObject = new object(); to avoid accidental exposure of your lock object. In your case the queue is protected, which means in a derived class it could be exposed publicly and a consumer of that derived class can add an extra lock for that. In that case the lock is out of your control.
Please prefer TryDequeue over Count check and Dequeue.
Please consider to use ConcurrentQueue or Synchronized Queue to avoid explicit locking.
5.1) As always measure implementations with different workloads, ConcurrentQueue vs Queue performance
According to my understanding holdTaskQueueItem will hold either one or zero item in it. A single Task or (Job/Computation) variable would be sufficient
Whenever you allow to your consumer to pass any function (in your case ConcumerCallback) then be very-very careful. What if it runs for minutes? What if it deadlocks because it also contains a lock inside it?