Integrating Ice with a GUI revisited
July 28th, 2008 | Filed under Ice | 1 commentI wrote a series of Connections articles in 2006 that explored issues with using Ice in a graphical application. The first three articles concentrated on the GUI event loop, and specifically on strategies for issuing remote invocations without adversely affecting the user experience. The central problem was that all remote invocations had the potential to block, even if they were oneway or asynchronous.
In my first article, which appeared in Issue 12, I introduced a call queue to avoid blocking the GUI thread. The idea was to write a class for each remote operation that the application needed to invoke; adding an instance of this class to the queue scheduled an invocation by a separate thread. The following code demonstrates the API:
class Call : public Shared
{
public:
virtual void execute() = 0;
};
typedef Handle<Call> CallPtr;
class SayHelloCall : public Call
{
public:
SayHelloCall(const HelloPrx& hello) :
_hello(hello)
{
}
void execute()
{
_hello->sayHello();
}
private:
const HelloPrx _hello;
};
CallQueuePtr queue = ...;
HelloPrx hello = ...;
queue->call(new SayHelloCall(hello));
The call queue implementation was relatively simple: a dedicated thread executed the queued calls in order, essentially serializing all of the invocations. A side-effect of this implementation is that it guaranteed strict ordering of twoway requests in the same server.
I enhanced the call queue in Issue 13 to support multiple concurrent requests using two different implementations: one using asynchronous invocations and the other using a thread pool. Both strategies sacrificed the strict ordering guarantee in order to improve throughput. I also introduced the idea of using multiple call queues for messages with different qualities of service (such as oneway, twoway, and batched messages).
I presented an advanced technique in Issue 14 that used the Ice router facility to eliminate the need to define a Call class for each remote operation. With this technique, invocations on Ice objects from the GUI are accomplished using the standard asynchronous API. The resulting code is considerably more straightforward:
class AMI_Hello_sayHelloI : public AMI_Hello_sayHello
{
public:
void ice_response()
{
}
void ice_exception(const Ice::LocalException&)
{
}
};
HelloPrx twoway = // Some twoway proxy.
twoway->sayHello_async(new AMI_Hello_sayHelloI());
The disadvantage of this technique was that, along with the complexity of having to implement an Ice router, oneway invocations were not handled gracefully. Instead, they required the use of a special request context key, similar to the one used by the Glacier2 router.
We were not very happy with this situation, and we went about fixing it in the Ice 3.3 release. To ensure that asynchronous calls never block, Ice 3.3 introduced some major changes to the internal architecture, which Benoit and Mark detailed in their article “Background I/O” in Issue 28 of Connections. The Ice run time now maintains its own queue of outstanding calls. Unlike my call queue implementations, Ice’s queue is hidden from the application and much more efficient because calls only need be queued if they cannot be sent immediately.
Contrary to what you might expect, however, synchronous oneway invocations such as the one shown below may still block:
HelloPrx twoway = // Some twoway proxy. HelloPrx oneway = HelloPrx::uncheckedCast(twoway->ice_oneway()); oneway->sayHello();
The call to sayHello can block the calling thread during connection establishment, while performing DNS lookups, or because the server is slow or non-responsive.
Why did we decide to preserve the existing semantics of synchronous oneways? It certainly would be possible to make oneway invocations non-blocking, but that would introduce a big problem: how would we report errors back to the application? What should Ice do if, for example, a DNS lookup failed? Since the call to sayHello would not be allowed to block, the DNS lookup would have to occur in a separate thread, so the call to sayHello could return. But once the call to sayHello has returned, there is no obvious way to report the error to the application.
Ice 3.3 solved this problem in a different way by adding support for asynchronous oneways. These work much like asynchronous twoways in that you have to define an AMI callback object. The primary difference is that ice_response is never called because oneway invocations do not have responses. If an error occurs while Ice attempts to send the message, the Ice run time invokes ice_exception on the callback as usual. The code below shows how to make an asynchronous oneway request:
class AMI_Hello_sayHelloI : public AMI_Hello_sayHello
{
public:
void ice_response()
{
assert false;
}
void ice_exception(const Ice::LocalException&)
{
}
};
HelloPrx twoway = // Some twoway proxy.
HelloPrx oneway = HelloPrx::uncheckedCast(twoway->ice_oneway());
oneway->sayHello_async(new AMI_Hello_sayHelloI());
One of the first applications to take advantage of the new non-blocking oneway semantics was IceStorm. Much effort went into prior versions of IceStorm to ensure that invocations from publishers would never block, a non-trivial task given that there was no simple way to make non-blocking invocations. With the new capabilities in Ice 3.3, I immediately re-architected and simplified the IceStorm internals to take advantage of the new non-blocking semantics. However, once I started performance and stress testing, I noticed that there was a new issue: there was no flow control on the non-blocking invocations. The upshot of this was that flooding IceStorm with events caused the memory usage to climb very quickly. To solve this problem we added a callback that Ice invokes after a queued request is actually sent. This can be used to flow-control oneway events.
To receive this notification, an AMI callback object must implement the Ice::AMISentCallback interface. Calling the asynchronous oneway method returns false if the message was queued, or true if the message was sent immediately. When the queued message is eventually sent, the Ice run time invokes the AMISentCallback::ice_sent method on the callback object:
class AMI_Hello_sayHelloI : public AMI_Hello_sayHello, public Ice::AMISentCallback
{
public:
void ice_sent()
{
/* called when the message is sent*/
}
// ice_response, ice_exception implementation here
};
HelloPrx twoway = // Some twoway proxy.
HelloPrx oneway = HelloPrx::uncheckedCast(twoway->ice_oneway());
if(!oneway->sayHello_async(new AMI_Hello_sayHelloI()))
{
// The request was queued, and ice_sent will be called
// once the message is sent.
}
The addition of the guaranteed non-blocking semantics renders much of the content of my first three articles obsolete. Developing GUI applications with Ice is now much more straightforward, and I can offer much simpler advice: all remote invocations made from the GUI should be asynchronous!
Earlier I mentioned the issue of strict ordering guarantees for twoway invocations. The call queue implementation in Issue 12 preserved the order of requests, whereas the subsequent strategies did not (and neither does the non-blocking asynchronous solution in Ice 3.3). If your application depends on the order in which requests are dispatched in the server, you must take additional measures such as disabling portions of the UI while a call is in progress.
Finally, since AMI callbacks are invoked by an Ice thread, it may not be safe for your callbacks to manipulate the GUI directly. This is the topic of my article in Issue 15, which is still as relevant as ever.