Software development

Full Send: How Data Race Safety Was Added to the ArcGIS Maps SDK for Swift

Introduction

Esri’s ArcGIS Maps SDK for Swift provides support for visualizing 2D and 3D geographic data and adding geospatial capabilities to a native iOS, macOS, or visionOS application. The public API is written in pure Swift and follows best practices, aiming to provide an excellent developer experience. We are always looking to improve, seeking to use the latest and greatest technologies. In the realm of native development for Apple platforms, the newest software technology is Swift 6. And with Swift 6 comes a major improvement in concurrency: data race safety, the culmination of years of hard work by the developers of the Swift language.

Data race safety sounds like a wonderful thing…and it is! It uses data isolation to guarantee that access to mutable state is mutually exclusive, preventing data races at runtime. Unfortunately, it isn’t as simple as just recompiling with Xcode 16. For most developers, there will be a plethora of build time issues to work through, followed by runtime issues when using the Swift 6 language mode.

The amount of work to make the switch from Swift 5 to Swift 6 can be quite daunting. There are new concepts and APIs to learn. There are cryptic warnings and errors with no clear solution. And just when you think you’re done, a perplexing crash is encountered while running a test, or worse, while a user is running your app or using your library.

My team and I have been there. And we learned several things along the way. We develop the ArcGIS Maps SDK for Swift and our goal is to share our data race safety journey with you, with the hope that you may learn from us and not repeat our mistakes. Even if you are not a Swift developer or have already migrated to Swift 6, you may find our story interesting and perhaps even entertaining.

First Step

The thing about new programming language versions is that you need to upgrade to get all the latest features. But what if there was a way to get those improvements incrementally instead of all at once? That was the approach taken by the Swift language developers. They anticipated that the path to data race safety would be difficult and made a way for it to be adopted incrementally.

The first step on our journey to data race safety was to change the Strict Concurrency Checking build setting from Minimal (the default) to Targeted. This enables concurrency checking only for code that has adopted concurrency, such as types that have adopted the Sendable protocol and async methods. Because the level of concurrency checking can be set per target, we were able to do this for each of our targets one at a time. This made the process less overwhelming by reducing the number of warnings and errors that we needed to address in each step of the process.

For this first step, there were over a hundred warnings! Suddenly code that had been working fine for years now triggered a warning that a value used in an asynchronous context was not sendable. What’s that all about? It means that the compiler cannot determine whether the value can be safely passed across concurrency domains, i.e. it doesn’t know whether the value is thread safe. It could be, but the compiler just does not know for sure.

In most cases, the solution is to make the type of the value adopt the Sendable protocol. That may seem simple because Sendable is a marker protocol, meaning that it has no required methods or properties that the adopting type must implement. But not so fast! The protocol has semantic requirements that are enforced at compile time. How to satisfy those requirements is different for each kind of type.

Our API has lots of classes, so not surprisingly most of the warnings were class-related, indicating that a class needed to be made sendable. This involved not only declaring that the class adopts Sendable but also ensuring that all mutable state was protected from race conditions. We used OSAllocatedUnfairLock for that, although the new Mutex type, added to the standard library in Swift 6 (SE-0433), is now a better choice as it effectively supersedes OSAllocatedUnfairLock.

It took one engineer working part time about a month to address all the targeted strict concurrency warnings. With that work out of the way, we were ready to move on to the next step.

Hard Mode

As much work as it was to address all the Targeted strict concurrency checking issues, that was the easy part. The next step was to change the strict concurrency mode from Targeted to Complete, which is the equivalent of enabling hard mode. Whereas Targeted enables concurrency checking only for code that has adopted concurrency, Complete enables concurrency checking for all code.

Attempting to build with Complete strict concurrency checking enabled resulted in hundreds of warnings and errors. Yikes! We knew this would be quite the undertaking, so we began chipping away at it, starting with the low hanging fruit, such as:

  • Make this struct adopt the Sendable protocol
  • Annotate that import with the preconcurrency attribute
  • Constrain these declarations to the main actor

The issue count was going down, but we still had a ways to go and the issues were getting more difficult to address.

Hundreds of classes needed to become sendable, but doing so wasn’t trivial. More locks were needed to ensure that mutable state was safe from data races. New concurrency constraints needed to be added to certain declarations, leading to other warnings in various places in our code.

Perhaps the largest issue was addressing changes in our generated code. Many of our types are generated from a common API specification which has no concept of sendability. We needed to modify our code generator to support generating types that are sendable, including a lock (when necessary) and appropriate use of that lock.

It took a couple engineers working part time a few months to address all the complete strict concurrency warnings. Finally, after months of work and dozens of pull requests, our goal of data race safety was ultimately achieved.

To Send or Not to Send?

When we first started adopting data race safety, we took the conservative approach. We knew that we had a lot to learn, so rather than making big mistakes up front, we went for smaller, more incremental changes until we knew what we were doing. Probably the clearest example of that is how we decided what types should adopt Sendable.

Initially, none of our public types were sendable. It was a newer protocol and we just didn’t have a need for it. Then we turned on targeted strict concurrency checking and suddenly there were several warnings indicating that certain types would need to be made sendable. However, upon further investigation, some warnings could be addressed in multiple ways.

One category of warnings pertained to static stored properties that were not concurrency-safe because they used a non-sendable type. The most obvious and straightforward solution was to make the type adopt Sendable. But another option that didn’t involve an API change was to convert the relevant property from stored to computed. We initially opted for the conservative approach that didn’t change the public API.

Over time, as our understanding of Swift concurrency increased, we grew more comfortable with making more significant changes. We made more and more types sendable. That led to the obvious question: should we just make every public type sendable?

At first, we decided that no, we shouldn’t make everything sendable. We’ll only adopt Sendable where it is necessary. But we also decided to revisit that decision in the future, knowing that our perspective may change over time. And it did! As we were addressing all the complete strict concurrency issues, more and more of our public types were becoming sendable to satisfy the compiler. We also realized that we couldn’t anticipate all the ways in which our types would be used externally. While we may not need a type to be sendable internally, a user might need that.

For those reasons, in the end we opted to make most of our public types sendable. That was more work on our part, but it ensured that our types would be as flexible as possible for our users.

Cupertino, We Have a Problem

So, there we were, as prepared for Swift 6 as we possibly could be. We had enabled complete strict concurrency checking (along with other relevant upcoming and experimental features) and had addressed every error and warning. The only thing left was to migrate to Swift 6 once it became available.

When the first Xcode 16 beta was released, we were excited to try it out. We opened our package, built the target, and…it failed. But with just a few warnings and errors that were easily addressed! All our hard work had paid off. We had no other Swift concurrency issues to address. That is, until we tested the latest release of our SDK with Xcode 16 Beta 1 and got some strange linker errors:

Undefined symbols for architecture arm64:
 "ArcGIS.Graphic.attributes.getter : [Swift.String : Any]"
 "ArcGIS.Graphic.__allocating_init(geometry: ArcGIS.Geometry?, attributes: [Swift.String : Any], symbol: ArcGIS.Symbol?) -> ArcGIS.Graphic", 
 ...

Note that those errors appeared while linking our SDK into an app, not while building our SDK. Our SDK built fine with Xcode 15.4, which is the version of Xcode that we were using at the time.

At first, we chalked it up to a bug, one that would hopefully be fixed in a subsequent beta. After all, it wouldn’t be the first time that an Xcode beta had some rough edges. So we weren’t that concerned.

As time went on and new betas were released, we would test them to see if the linker errors went away. And to our surprise, with each new beta, the problem persisted. So we did what we probably should have done with the first beta: we submitted feedback to Apple through Feedback Assistant. And to increase our chances of the problem being resolved before the final release of Xcode 16, we requested code level support from Apple.

While Apple was investigating, we narrowed down the problem on our end. We found that by removing the preconcurrency attribute from the offending declarations, everything worked correctly. This was most puzzling, but we forwarded that information to Apple, hoping it would aid their investigation.

After some time, we heard back from Apple. They confirmed that the linker errors we encountered were caused by a bug…just not a bug in Xcode 16. The issue was that the linker included with Xcode 15.4 had a bug, which meant that the problem was baked into our latest release. If we did nothing, any user that tried to use our latest release with Xcode 16 would be unable to build!

Based on our findings and the result of Apple’s investigation, we were able to quickly create, test, and release a bugfix update that fixed the problem. Our solution was simply to remove the preconcurrency attribute from the offending declarations. That had the potential for causing build warnings (Swift 5 language mode) or errors (Swift 6 language mode), but the likelihood of that was quite small. Plus, those issues could be addressed by the user, whereas the linker errors could not be resolved by the user no matter what they did.

We want to thank Apple for their help. Our experiences with our assigned Developer Technical Support Engineer were phenomenal and the assistance they provided was essential to rightly understanding the problem and determining how best to address it.

Conclusion

Adopting data race safety in our SDK was a daunting task. We knew it would be difficult and time consuming, but we also knew that the rewards would be worth it. Anticipating that our users would benefit from the work, we started as early as we could. We adopted upcoming and even experimental features with the goal of complete data race safety by the time Swift 6 was released. And we met our goal! With the latest version of our SDK, our customers aren’t be waiting on us to support Swift 6 and all the latest concurrency features.

Through this process, we learned a lot. Planning ahead was key. Had we waited until Swift 6 was released to start this effort, we would still be working on data race safety in our SDK. It was also important that we proceeded cautiously. We didn’t want to rush into something that we didn’t understand, which would only lead to further mistakes. Instead, we made baby steps at first and then increased our pace as our understanding grew. In addition, staying informed was essential. We closely followed the Swift forums, particularly the Evolution category, seeking to understand the trajectory of Swift development and how we could leverage new features to benefit our customers.

We’ve shown in this blog post our willingness to invest significant time and energy into creating a product that will make our customers more successful. One way we do that is by anticipating their needs and providing desired functionality before it is even requested. Ultimately, the goal is to create the best software that we can for our customers. We hope we accomplished this goal with the 200.6 release of the ArcGIS Maps SDK for Swift.

We’re proud to make technology that matters and to support our customers in doing important work that makes a difference for society and the planet. We regularly have opportunities to continue expanding our teams. If you’re interested in joining us, check out our open positions!

About the author

I am a Principle Software Engineer on the ArcGIS Maps SDK for Swift team at Esri. When I'm not programming, I enjoy studying the Bible and spending time with my wife and six children.

Next Article

Why Attend Developer Conferences

Read this article