A second WP_Query in a template is harmless right up to the point where you call the_post() on it. That method assigns the global $post and runs setup_postdata(), so every template tag after the loop — the_title(), the_permalink(), the comment form — describes the last item of the secondary loop instead of the page being viewed.
$related = new WP_Query(array(
'category__in' => wp_get_post_categories($post->ID),
'post__not_in' => array($post->ID),
'posts_per_page' => 4,
));
while ($related->have_posts()) {
$related->the_post();
get_template_part('loop', 'related');
}
wp_reset_postdata();
The reset restores $post from $wp_query->post, and it has to run whether or not the loop returned anything. It is not the same call as wp_reset_query(): that one puts the global $wp_query back as well, and it is what you want after query_posts(), which replaces the main query outright. A secondary WP_Query never touches $wp_query, so the postdata reset is enough — and a loop that reads properties off the object rather than calling the_post() needs no reset at all.