Memory Pools – Revisited

It is Friday morning. The day started with a thunderstorm and some rain. After breakfast the storms were done. Last Thursday afternoon we had powerful storms with lots of rain. Some roads in the area had about two inches of water during the storms. Cars on roads were splashing water all over. I did not hear about accidents. That was good news!

After my first 2-hour block I sat with my wife and chatted for a few minutes. We were discussing the fact that for most people their normal state does not reflect happiness. It is a shame because such state just builds on itself and continues to spiral downwards. We try to be approachable both at home and work. People like and appreciates such behavior.

OK, enough chit chat. Let’s get to the subject of this post.

A couple years ago I generated the post in this blog name Memory Pools. Not sure it conveyed all my thoughts. A couple days ago I was reading an article in Communications of the ACM titled “The Dogged Pursuit of Bug-Free C Programs: The Frama-C Software Analysis Platform”. I enjoy using the C programming language and have developed several commercial products using it. I believe I have written several million lines of production code in C. The C programming language is very powerful but you can get in trouble easier than with other programming languages. That said; the final product seems to run faster because it is very close to assembly language.

In this post we will implement a simple memory allocation supporting multiple heaps in which all requested blocks will be of the same length. Let’s first look at the code that I generated on Visual Studio 2019 Enterprise Edition.

// **** Memory Pool related ****
#define	MAX_MEMORY_POOLS	1									// was: 8
#define	MAX_POOL_CAPACITY	1024
#define ALLOCATION_SIZE		128


HANDLE			mpMutex[MAX_MEMORY_POOLS];

void			*memoryHeap[MAX_MEMORY_POOLS][MAX_POOL_CAPACITY / ALLOCATION_SIZE];

int				tailIndex[MAX_MEMORY_POOLS];

We have three constants defined. The first indicates the number of memory pools we will support. For simplicity the constant has been set to 1 in the code. I have tested the code with several values. The second constant specifies the total size of a heap. You can make it larger or smaller. The final constant represents the size of all memory allocations.

We also have defined three variables. Each variable is an array. The second is two-dimensional array, while the first and last are one dimensional.

The first is an array of mutex. In general when a problem in an interview is given, one does not need to take into account multiple accesses to a function / method. In practice things are not that simple. I use a mutex to grant access to memory allocations and releases. If you are not interested in implementing such functions, please delete them from the code.

The second array holds the not allocated blocks of memory per heap.

The third and last array holds an integer that represents the next free memory block in each heap. When the value associated with a partition / heap reaches 0, the heap is empty (no more memory can be allocated until some is return to the specified heap).

int __stdcall	MemoryPool	(
							)

//	***************************************************************@@
//	- Exercise the memory pool(s).
//	*****************************************************************

{

int				memPoolID,										// memory pool ID
				retVal,											// returned by this function
				status;											// returned by function calls

unsigned long	size;											// size of memory to allocate

void			*memory;										// memory to be allocated

#ifdef CAKE
int				traceExecution = 1;								// for testing only
#endif

// **** initialization ****
retVal		= 0;												// hope all goes well

memory		= NULL;												// for starters
size		= ALLOCATION_SIZE;									// for starters

// **** flag that all memory heaps have not been initialized yet ****
memset((void*)tailIndex, (int)0xff, (size_t)(MAX_MEMORY_POOLS * sizeof(int)));

// **** inform the user what is going on ****
EventLog(EVENT_INFO,
"MemoryPool <<< initializing all memory heaps line: %d ...\n",
__LINE__);

// **** initialize ALL the memory heaps ****
for (memPoolID = 0; memPoolID < MAX_MEMORY_POOLS; memPoolID++)
	{
	status = MPInit(memPoolID);
	if (status != 0)
		{
		EventLog(EVENT_ERROR,
		"MemoryPool <<< MPInit status: %d memPoolID: %d line: %d file ==>%s<==\n",
		status, memPoolID, __LINE__, __FILE__);
		retVal = status;
		goto done;
		}
	}

// **** inform the user what is going on ****
for (memPoolID = 0; traceExecution != 0 && memPoolID < MAX_MEMORY_POOLS; memPoolID++)
	{
	for (int i = 0; i < MAX_POOL_CAPACITY / ALLOCATION_SIZE; i++)
		EventLog(EVENT_INFO,
		"MemoryPool <<< memoryHeap[%d][%d]: 0x%08lx line: %d\n",
		memPoolID, i, (unsigned long)memoryHeap[memPoolID][i], __LINE__);
	}

// **** inform the user what is going on ****
EventLog(EVENT_INFO,
"MemoryPool <<< allocate and free memory from the heaps line: %d ...\n",
__LINE__);

// **** allocate and free memory from all pools ****
for (int memPoolID = 0; memPoolID < MAX_MEMORY_POOLS; memPoolID++)
	{

	// **** allocate memory from the specified pool ****
	for (int i = 0; i < 17; i++)									// 5, 8, 9, 13, 17
		{

		// **** allocate memory from this pool ****
		status = MPAlloc(	memPoolID,
							mpMutex[memPoolID],
							size,
							&memory);

		switch (status)
			{
			case 0:

				// **** all is well so far ****

			break;

			case WAR_NO_MORE_MEMORY:
				EventLog(EVENT_ERROR,
				"MemoryPool <<< MPAlloc WAR_NO_MORE_MEMORY size: %lu i: %d line: %d file ==>%s<==\n",
				size, i, __LINE__, __FILE__);
				//retVal = status;
				//goto done;
				continue;
			break;

			default:
				EventLog(EVENT_ERROR,
				"MemoryPool <<< MPAlloc status: %d size: %lu i: %d line: %d file ==>%s<==\n",
				status, size, i, __LINE__, __FILE__);
				retVal = status;
				goto done;
			break;
			}

		// **** use the memory block ****
		status = sprintf(	(char*)memory, 
							"memPoolID: %d i: %d", 
							memPoolID, i);
		if (status <= 0)
			{
			EventLog(EVENT_ERROR,
			"MemoryPool <<< sprintf status: %d line: %d file ==>%s<==\n",
			status, __LINE__, __FILE__);
			retVal = WAR_INTERNAL_ERROR;
			goto done;
			}

		// **** inform the user what is going on ****
		EventLog(EVENT_INFO,
		"MemoryPool <<< memory: 0x%08lx memory ==>%s<== line: %d\n",
		(unsigned long)memory, memory, __LINE__);

		// **** free this memory (if needed) ****
		if (i % 3 == 0)
			{
			status = MPFree(memPoolID, memory);
			if (status != 0)
				{
				EventLog(EVENT_ERROR,
				"MemoryPool <<< MPFree status: %d memory: 0x%08lx line: %d file ==>%s<==\n",
				status, (unsigned long)memory, __LINE__, __FILE__);
				retVal = status;
				goto done;
				}
			}
		}
	}

// **** clean up ****
done:

// **** inform the user what is going on ****
if ((retVal != 0) || (traceExecution != 0))
	EventLog(EVENT_INFO,
	"MemoryPool <<< retVal: %d line: %d file ==>%s<==\n",
	retVal, __LINE__, __FILE__);

// **** inform the caller what went on ****
return retVal;
}

This is the test program that we will use to test our memory pools.

The symbol CAKE is never defined. By convention we change the symbol to something else when we want the enclosed code to be parsed / executed. We could have used 0 and 1 but this convention was created long time ago.

The function defines a few variables whose use is easy to determine.

We then display a message. In practice the EventLog function writes information to log files. If you are interested in running this code, replace the call with printf. The first argument can be removed / ignored.

The first loop is used to initialize the memory heaps. We call MPInit with the proper memory pool ID for each heap. If something goes wrong the function will return a non-zero status, a message will be logged and the program will perform some clean up (if needed) and will then exit. This is a pattern that I commonly use when developing in C.

After the heap initialization we will display, if needed, the contents of the memoryHeap array. This will allow us to verify if the memory allocations worked as expected.

After displaying a message we will allocate and free memory from the heaps.

The following loop allocates memory from all the heaps. If the allocation fails, a message is displayed and execution continues. This is done to test issues with the allocation.

Once we have a block allocated we write some data to it. This is done to track the blocks through the process of allocating and releasing memory. I will make additional comments on this subject later on.

After displaying the contents of the memory block, we decide if it will be released or not by the MPFree function.

After the traversing all the memory pools the code exits.

08/27/21 09:46:30 0x00003bcc - MemoryPool <<< initializing all memory heaps line: 59393 ...
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< allocate and free memory from the heaps line: 59421 ...
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< memory: 0x0085c790 memory ==>memPoolID: 0 i: 0<== line: 59479
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< memory: 0x0085c790 memory ==>memPoolID: 0 i: 1<== line: 59479
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< memory: 0x0085c708 memory ==>memPoolID: 0 i: 2<== line: 59479
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< memory: 0x0085bd78 memory ==>memPoolID: 0 i: 3<== line: 59479
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< memory: 0x0085bd78 memory ==>memPoolID: 0 i: 4<== line: 59479
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< memory: 0x0085c680 memory ==>memPoolID: 0 i: 5<== line: 59479
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< memory: 0x0085c818 memory ==>memPoolID: 0 i: 6<== line: 59479
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< memory: 0x0085c818 memory ==>memPoolID: 0 i: 7<== line: 59479
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< memory: 0x0085be88 memory ==>memPoolID: 0 i: 8<== line: 59479
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< memory: 0x0085c240 memory ==>memPoolID: 0 i: 9<== line: 59479
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< memory: 0x0085c240 memory ==>memPoolID: 0 i: 10<== line: 59479
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< memory: 0x0085c3d8 memory ==>memPoolID: 0 i: 11<== line: 59479
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< MPAlloc WAR_NO_MORE_MEMORY size: 128 i: 12 line: 59448 file ==>C:\SencorSource\source\testlib.c<==
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< MPAlloc WAR_NO_MORE_MEMORY size: 128 i: 13 line: 59448 file ==>C:\SencorSource\source\testlib.c<==
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< MPAlloc WAR_NO_MORE_MEMORY size: 128 i: 14 line: 59448 file ==>C:\SencorSource\source\testlib.c<==
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< MPAlloc WAR_NO_MORE_MEMORY size: 128 i: 15 line: 59448 file ==>C:\SencorSource\source\testlib.c<==
08/27/21 09:46:30 0x00003bcc - MemoryPool <<< MPAlloc WAR_NO_MORE_MEMORY size: 128 i: 16 line: 59448 file ==>C:\SencorSource\source\testlib.c<==

This snippet was generated by specifying a single heap.

The snippet is from the associated log file. It seems that the allocations and releases of memory worked as expected. Note that we looped several times past the available memory in the heap. This is indicated by the error messages in the last lines of the log snippet.

int __stdcall	MPInit	(
						int		mpName
						)

//	***************************************************************@@
//	- Initialize the specified memory pool.
//	*****************************************************************

{

int			retVal,												// returned by this function
			status;												// returned by function calls

#ifdef CAKE
int			traceExecution = 1;									// for testing only
#endif

// **** initialization ****
retVal		= 0;												// hope all goes well

// **** inform the user what is going on ****
if (traceExecution != 0)
	EventLog(EVENT_INFO,
	"MPInit <<< mpName: %d line: %d\n",
	mpName, __LINE__);

// **** perform sanity checks ****
if (MAX_POOL_CAPACITY % ALLOCATION_SIZE != 0)
	{
	EventLog(EVENT_ERROR,
	"MPInit <<< UNEXPECTED MAX_POOL_CAPACITY: %d ALLOCATION_SIZE: %d line: %d file ==>%s<==\n",
	MAX_POOL_CAPACITY, ALLOCATION_SIZE, __LINE__, __FILE__);
	retVal = WAR_SYSTEM_CONFIGURATION_ERROR;
	goto done;
	}

if ((mpName < 0) ||
	(mpName > MAX_MEMORY_POOLS))
	{
	EventLog(EVENT_ERROR,
	"MPInit <<< UNEXPECTED mpName: %d line: %d file ==>%s<==\n",
	mpName, __LINE__, __FILE__);
	retVal = WAR_INVALID_ARGUMENT_VALUE;
	goto done;
	}

if (tailIndex[mpName] != -1)
	{
	EventLog(EVENT_ERROR,
	"MPInit <<< UNEXPECTED tailIndex[%d]: %d line: %d file ==>%s<==\n",
	mpName, tailIndex[mpName], __LINE__, __FILE__);
	retVal = WAR_HEAP_ALREADY_INITIALIZED;
	goto done;
	}

// **** loop allocating memory for this heap ****
for (int i = 0; i < MAX_POOL_CAPACITY / ALLOCATION_SIZE; i++)
	{
	memoryHeap[mpName][i] = (void*)calloc(	(size_t)1,
											(size_t)ALLOCATION_SIZE);
	if (memoryHeap[mpName][i] == (void*)NULL)
		{
		EventLog(EVENT_ERROR,
		"MPInit <<< UNEXPECTED calloc == NULL mpName: %d i: %d line: %d file ==>%s<==\n",
		mpName, i, __LINE__, __FILE__);
		retVal = WAR_NO_MORE_MEMORY;
		goto done;
		}
	}

// **** set tail index ****
tailIndex[mpName] = MAX_POOL_CAPACITY / ALLOCATION_SIZE;

// **** inform the user what is going on ****
if (traceExecution != 0)
	EventLog(EVENT_INFO,
	"MPInit <<< tailIndex[%d]: %d line: %d\n",
	mpName, tailIndex[mpName], __LINE__);

// **** initialize the mutex used to allocate memory for this pool ****
status = MutexInit(	&mpMutex[mpName],
					(BOOL)(0 == 1));
if (status != 0)
	{
	EventLog(EVENT_ERROR,
	"MPInit <<< MutexInit status: %d mpName: %d line: %d file ==>%s<==\n",
	status, mpName, __LINE__, __FILE__);
	retVal = status;
	goto done;
	}

// **** inform the user what is going on ****
if (traceExecution != 0)
	EventLog(EVENT_INFO,
	"MPInit <<< mpMutex[%d]: 0x%08lx line: %d\n",
	mpName, (unsigned long)mpMutex[mpName], __LINE__);

// **** clean up ****
done:

// **** inform the user what is going on ****
if ((retVal != 0) || (traceExecution != 0))
	EventLog(EVENT_INFO,
	"MPInit <<< retVal: %d line: %d file ==>%s<==\n",
	retVal, __LINE__, __FILE__);

// **** inform the caller what went on ****
return retVal;
}

The MPInit function initializes a memory pool by name.

From this point moving forward I will not comment on the information log messages. They are used to debug the application / system.

We first check that there is not a last block of different size in a pool. This occurs when the pool capacity divided by the allocation size has a remainder.

We check that the mpName for the memory pool is within limits. Note that we should also check if the memory pool has already been initialized. We do not want to initialize multiple times the same memory pool. We could address this limitation by adding a Boolean array indicating if a memory pool has been initialized or by initializing the tailIndex to -1 which would not require an additional array.

We then set the associated tail index tailIndex to -1. This will prevent us from initializing a memory pool more than once.

At this point we enter a loop and allocate a block of memory at a time.

We then set the tail index to point. Note that the index is set past the size of the array. When allocating memory we first need to decrement the index.

We initialize a mutex that will be used to protect this memory pool when allocating and freeing memory.

At this point we are ready to perform operations in the specified memory pool.

int __stdcall	MPAlloc	(
						int				mpName,
						HANDLE			mpMutex,
						unsigned long	size,
						void			**memory
						)

//	***************************************************************@@
//	- Allocate memory from the specified memory pool.
//	*****************************************************************

{

BOOL		gotMutex;											// got mutex flag

int			retVal,												// returned by this function
			status;												// returned by function calls

#ifdef CAKE
int			traceExecution = 1;									// for testing only
#endif

// **** initialization ****
retVal		= 0;												// hope all goes well

gotMutex	= (BOOL)(0 == 1);									// for starters

// **** perform sanity checks ****
if ((mpName < 0) ||
	(mpName >= MAX_MEMORY_POOLS))
	{
	EventLog(EVENT_ERROR,
	"MPAlloc <<< UNEXPECTED mpName: %d line: %d file ==>%s<==\n",
	mpName, __LINE__, __FILE__);
	retVal = WAR_INVALID_ARGUMENT_VALUE;
	goto done;
	}

// **** inform the user what is going on ****
if (traceExecution != 0)
	EventLog(EVENT_INFO,
	"MPAlloc <<< mpName: %d line: %d\n",
	mpName, __LINE__);

// **** check the allocation size ****
if (size != (unsigned long)ALLOCATION_SIZE)
	{
	EventLog(EVENT_ERROR,
	"MPAlloc <<< UNEXPECTED size: %ld != ALLOCATION_SIZE: %ld line: %d file ==>%s<==\n",
	size, ALLOCATION_SIZE, __LINE__, __FILE__);
	retVal = WAR_INVALID_ARGUMENT_VALUE;
	goto done;
	}

// **** inform the user what is going on ****
if (traceExecution != 0)
	EventLog(EVENT_INFO,
	"MPAlloc <<< size: %ld line: %d\n",
	size, __LINE__);

// **** check the memory pointer ****
if (memory == (void *)NULL)
	{
	EventLog(EVENT_ERROR,
	"MPAlloc <<< UNEXPECTED memory: 0x%08lx line: %d file ==>%s<==\n",
	memory, __LINE__, __FILE__);
	retVal = WAR_INVALID_ARGUMENT_VALUE;
	goto done;
	}
*memory = (void *)NULL;

// **** inform the user what is going on ****
if (traceExecution != 0)
	EventLog(EVENT_INFO,
	"MPAlloc <<< mpMutex: 0x%08lx line: %d\n",
	(unsigned long)mpMutex, __LINE__);

// **** get access to the mutex ****
status = MutexLock(&mpMutex);
CDP_CHECK_STATUS("MPAlloc <<< MutexLock", status);

// **** flag that we have the mutex ****
gotMutex = (BOOL)(1 == 1);

// **** inform the user what is going on ****
if (traceExecution != 0)
	EventLog(EVENT_INFO,
	"MPAlloc <<< tailIndex[%d]: %d line: %d\n",
	mpName, tailIndex[mpName], __LINE__);

// **** check if we are out of memory in this heap ****
if (tailIndex[mpName] <= 0)
	{
	if (traceExecution != 0)
		EventLog(EVENT_ERROR,
		"MPAlloc <<< UNEXPECTED calloc == NULL tailIndex[%d]: %d line: %d file ==>%s<==\n",
		mpName, tailIndex[mpName], __LINE__, __FILE__);
	retVal = WAR_NO_MORE_MEMORY;
	goto done;
	}

// **** get memory from the heap ****
*memory = memoryHeap[mpName][--tailIndex[mpName]];

// **** inform the user what is going on ****
if (traceExecution != 0)
	EventLog(EVENT_INFO,
	"MPAlloc <<< memory: 0x%08lx *memory ==>%s<== line: %d\n",
	(unsigned long)memory, *memory, __LINE__);

// **** clean up ****
done:

// **** release the mutex (if needed) ****
if (gotMutex)
	{
	status = MutexUnLock(&mpMutex);
	if (status != 0)
		EventLog(EVENT_ERROR,
		"MPAlloc <<< MutexUnLock status: %d mpName: %d line: %d file ==>%s<==\n",
		status, mpName, __LINE__, __FILE__);
	}

//// **** inform the user what is going on ****
//if ((retVal != 0) || (traceExecution != 0))
//	EventLog(EVENT_INFO,
//	"MPAlloc <<< retVal: %d line: %d file ==>%s<==\n",
//	retVal, __LINE__, __FILE__);

// **** inform the caller what went on ****
return retVal;
}

This function is used to allocate memory blocks of the specified size. Since the size is a constant for all memory pools, in this implementation we would not need to specify the size of the requested block nor check it against a constant. This was done for future enhancements which we will not cover in this post.

The name of the memory pool is checked to make sure it is within the specified boundaries.

The memory pointer is checked and set to null in case something goes wrong.

We then obtain the mutex associated with the specified memory pool. Since we need to free the mutex when done, we set the flag to true to indicate we need to free the mutex before exiting this function.

If the tail index is <= 0, the pool is empty. We flag an error and go to the end of the function to perform cleanup operations (as needed).

If the tail index is > 0, we have at least one memory block in the pool to return. We assign the next free block to the memory variable.

Before leaving the function, we make sure to release the mutex, if needed, associated with this memory pool.

If all went well the MPAlloc function returns a block of the specified size to the caller.

int __stdcall	MPFree	(
						int		mpName,
						void	*memory
						)

//	***************************************************************@@
//	- Free (return to heap) the specified memory block into the 
//	specified memory pool.
//	*****************************************************************

{

BOOL		gotMutex;											// got mutex flag

int			retVal,												// returned by this function
			status;												// returned by function calls

#ifdef CAKE
int			traceExecution = 1;									// for testing only
#endif

// **** initialization ****
retVal		= 0;												// hope all goes well

gotMutex	= (BOOL)(0 == 1);									// for starters

// **** perform sanity checks ****
if ((mpName < 0) ||
	(mpName >= MAX_MEMORY_POOLS))
	{
	EventLog(EVENT_ERROR,
	"MPFree <<< UNEXPECTED mpName: %d line: %d file ==>%s<==\n",
	mpName, __LINE__, __FILE__);
	retVal = WAR_INVALID_ARGUMENT_VALUE;
	goto done;
	}
// **** inform the user what is going on ****
if (traceExecution != 0)
	EventLog(EVENT_INFO,
	"MPFree <<< memory ==>%s<== line: %d\n",
	memory, __LINE__);

// **** get access to the mutex ****
status = MutexLock(&mpMutex[mpName]);
CDP_CHECK_STATUS("MPFree <<< MutexLock", status);

// **** flag we have the mutex ****
gotMutex = (BOOL)(1 == 1);

// **** return the memory block to the heap ****
memoryHeap[mpName][tailIndex[mpName]] = memory;

// **** increment index ****
tailIndex[mpName]++;

// **** inform the user what is going on ****
if (traceExecution != 0)
	EventLog(EVENT_INFO,
	"MPFree <<< tailIndex[%d]: %d line: %d\n",
	mpName, tailIndex[mpName], __LINE__);

// **** clean up ****
done:

// **** release the mutex (if needed) ****
if (gotMutex)
	{
	status = MutexUnLock(&mpMutex[mpName]);
	if (status != 0)
		EventLog(EVENT_ERROR,
		"MPFree <<< MutexUnLock status: %d mpName: %d line: %d file ==>%s<==\n",
		status, mpName, __LINE__, __FILE__);
	}

// **** inform the user what is going on ****
if ((retVal != 0) || (traceExecution != 0))
	EventLog(EVENT_INFO,
	"MPFree <<< retVal: %d line: %d file ==>%s<==\n",
	retVal, __LINE__, __FILE__);

// **** inform the caller what went on ****
return retVal;
}

The MPFree function frees / returns the specified memory block to the specified memory pool. Note that in this code we do not keep track of the memory for each pool. It is possible to return valid memory to a different pool in addition to return invalid memory to any pool. To avoid these issues we could keep track of the memory blocks associated with each pool, or instead of allocating just a block for use by the caller, we would include before the memory returned information about the pool the memory belongs to. In some cases it would also be a good idea to include the size of the block.

In our MPFree function we check the range of the names of the memory pool.

We get access to the mutex protecting the free memory operations and flag we own the mutex.

Then we return the memory block to the specified pool. The index is incremented.

When all is said in done, we make sure we release the mutex if needed.

Note that this implementation, not considering the mutex array, we are using an array to keep track of the blocks and another to keep track of the tail of each memory pool. If we refer to a single memory pool we would have a solution of space O(2 * n). In the next post, I will attempt to produce a solution of space O(1).

Hope you enjoyed solving this problem as much as I did. I will not be posting the associated code for this post. If interested, please copy and paste the code in your favorite IDE. You will have to edit it to remove the mutex functions and replace the EventLog function with a printf.

If you have comments or questions regarding this, or any other post in this blog, please do not hesitate and leave me a note below. I will reply as soon as possible.

Keep on reading and experimenting. It is one of the best ways to learn, become proficient, refresh your knowledge and enhance your developer toolset.

Thanks for reading this post, feel free to connect with me John Canessa at LinkedIn.

Regards;

John

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.