Are there pitfalls to this solution to read messages from a queue in parallel?
Readability  

Use keyword var any time that the initialization of the variable clearly tells what the variable represents. Avoid abbreviations in variable names.

var messageQueue = new MessageQueue(queue);
var semaphore = new SemaphoreSlim(15, 15);




Configuration

Avoid hard-coded settings:


var semaphore = new SemaphoreSlim(15, 15);



Make use of a pattern to allow the number of concurrent processing jobs to be configurable (instance property, configuration file, ..).



Main Loop

The main loop while (true) is considered a code smell by some because it's not clear what the exit conditions are. But what's definately an issue is that the loop never exits. Always make sure the loop has a graceful exit. Since there's a cancellation token provided, why not use it to drive the loop? 

sand-box mode:

while (!token.IsCancellationRequested)
{
    // .. loop body
}


explicit mode:

while (true)
{
    token.ThrowIfCancellationRequested();
    // .. loop body
}




Threading Integrity

Once you have acquired the semaphore, you should immediately use the try-finally block to make sure to always release it.


await semaphore.WaitAsync();

// What if the code below throws an exception? The semaphore is not released!
await PeekAsync(messageQueue);
var message = await ReceiveAsync(messageQueue);



await semaphore.WaitAsync();
try
{
    // .. code while holding lock
}
finally 
{
    semaphore.Release();
}




MessageQueue

There is no reason to peek before receiving, because both operations are blocking on the same trigger - an incoming message.


await PeekAsync(messageQueue);
var message = await ReceiveAsync(messageQueue);



You can replace the above with:

var message = await ReceiveAsync(messageQueue);